AI Coding Agents Keep Leaving RLS Off — Catch It Before You Push
AI coding agents leave Row Level Security off because nobody asks for it. Tell Cursor, Lovable, v0, or Claude to "add a profiles table and let users save their settings" and the model writes the create table, wires the query, and ships a working UI. It does not run enable row level security, and it does not write a policy — that wasn't in the prompt, and the feature demos perfectly without it. The result is a table that anyone holding your public anon key can read and write. You catch it in review or CI, or you find out the way everyone else does.
This isn't a knock on any one tool. It's structural. The part of a Supabase app that protects data lives outside the happy-path prompt, so an agent optimizing for "it works in the preview" routinely skips it. Below: why that happens, why an unprotected table is a public API, and how to catch a missing policy in a diff before it reaches production.
Why do AI coding agents generate Supabase tables without RLS?
Because enabling RLS makes nothing visibly better in the demo, and the demo is the agent's reward signal. When you describe a feature, the model translates it into the minimum schema and frontend that satisfies the request. A create table statement makes the dashboard query return rows. An enable row level security statement makes that same query return nothing until a matching policy exists. To a model optimizing for a working preview, adding RLS without a scoped policy looks like it broke the feature — so the safe-looking move is to leave it off.
There's a second reason, and it's specific to Supabase: new tables in the public schema are reachable without RLS by default. The auto-generated PostgREST API exposes every public table through the anon key that ships in your browser bundle by design. So an agent can build a fully functional app — sign-up, dashboard, saved data — without ever touching a policy, and every screen works. The table being wide open is invisible until someone queries it directly.
That is the exact failure mode behind CVE-2025-48757. In May 2025, security researcher Matt Palmer disclosed that Lovable-generated apps were shipping with missing or insufficient Supabase Row Level Security. The CVE record rates it CVSS 9.3 (Critical), and the official description states the gap "allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites." Palmer's scan found 303 endpoints across 170 production apps leaking user emails, payment status, and in some cases third-party API keys. No exploit chain — just the documented REST API answering a request against a table nobody locked. For the full teardown, see our CVE-2025-48757 write-up on AI-built data leaks.
Why a table without RLS is a public API endpoint
People assume the anon key is a secret, so an unprotected table feels gated. It isn't. The anon key is public by design — it's in your client bundle, and so is your project URL. Neither is the protection layer. RLS is. With RLS off (or on but with no policy denying the anon role), anyone who reads your bundle can run this from a laptop:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR_ANON_KEY"
And get back every row: every user's email, settings, whatever you stored. Worse, Supabase's default grants cover both anon and authenticated for INSERT, UPDATE, and DELETE, so the same endpoint accepts writes and deletes too. A table without RLS is a fully open CRUD API, not a read-only leak.
Three things are true at once, which is why this ships so easily:
- It's silent. No error, no failing test, no deploy warning. The app works perfectly in the preview, which is exactly why the hole survives to production.
- The agent optimizes against you. "The feature works in the demo" is the reward, and a locked-down table makes the demo no better — so the security control is the first thing dropped.
- You didn't write it, so you don't review it. The whole appeal of an agent is not reading every line. That's fine for a layout component and fatal for the statement that decides who can read your users' table.
What GuardLayer actually checks here
Be precise about the tool, because this is a security product and the honest scope is the point. GuardLayer is a static scanner. It reads your Next.js + Supabase code, migrations, and config, and flags tables you create without RLS, leaked keys, and unscoped policies. It does not run your app, trace data flow at runtime, or watch what your AI agent does in production — there is no taint analysis under the hood, and it does not inspect MCP servers or stop prompt injection. It answers one narrow, high-value question: did the code your agent wrote leave a table open? That's a check you want on every push, and exactly the kind of thing a human reviewer skims past.
If you want the wider context for why agents quietly cross security boundaries, the OWASP Agentic AI Top 10 and Simon Willison's writing on the "lethal trifecta" both circle the same root cause: AI systems happily produce working output that violates a constraint nobody stated. RLS-off is the database-shaped version of that — and it's the part GuardLayer can verify statically.
How to catch missing RLS before you push
You have two lines of defense: a quick manual pass and an automated gate. Use both.
The 30-second manual check
Run these against the repo your agent just touched, before you commit:
# Every table-creating migration — each one needs a matching RLS enable + policy
grep -rniE 'create table' supabase/migrations
# Did the agent enable RLS anywhere? Compare the count to the line above.
grep -rniE 'enable[[:space:]]+row[[:space:]]+level[[:space:]]+security' supabase/migrations
If the first command finds more tables than the second finds enable statements, you have tables shipping without RLS. Then ask Postgres directly which public tables are open right now (run in the Supabase SQL editor):
select tablename
from pg_tables
where schemaname = 'public'
and not rowsecurity;
Anything that query returns is reachable by anon this second. If you didn't intend that table to be public, it's exposed.
The fix the agent should have written
Don't disable RLS to make a 403 go away — that's the opposite of the fix and re-opens the table. Enable RLS and write the policy that grants exactly the access the feature needed:
alter table public.profiles enable row level security;
create policy "Users read own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
auth.uid() returns the authenticated user's UUID and null for the anon role, so anonymous callers fail the comparison and get nothing. Wrapping it as (select auth.uid()) lets Postgres evaluate it once per statement instead of once per row — Supabase's own RLS performance guidance — without changing the security behavior.
Then watch for the trap the agent reaches for next: a policy with using (true). It makes the 403 disappear and looks like RLS is "on," but using (true) grants every row to everyone — it re-opens the table you just locked. A policy has to actually scope access (auth.uid() = user_id) to protect anything.
The automated gate
Manual checks rely on you remembering, every time, across every agent session. The durable fix is to move the check into CI so a missing policy can't merge. GuardLayer scans your Next.js + Supabase repo on every push, flags tables created without RLS, leaked keys, and unscoped using (true) policies, posts the findings as a PR comment, and gates the merge before the hole reaches production. It's the second reviewer for the code your agent wrote that you didn't read.
For the broader pattern across tools, our vibe-coding security teardown maps each recurring failure mode to its fix, and the Next.js + Supabase security checklist is the pre-ship list to run by hand.
FAQ
Why does Cursor / Lovable / v0 create tables without RLS? Because enabling RLS without a matching policy makes the feature return no rows in the demo, which looks like a regression to a model optimizing for "it works." Leaving RLS off keeps every query passing, so that's the default output. The agent builds the product; the security boundary is left to you.
Is the Supabase anon key safe to expose if a table has no RLS? No. The anon key is public by design and lives in your client bundle, as does your project URL. RLS is the protection layer, not the keys. With RLS off, the anon key is all an attacker needs to dump — or write to — the table.
Does enabling RLS break my AI-generated app?
It changes "anyone can read this table" to "only the right user can." If a query returns empty after you enable RLS, that's the signal you were missing a policy — add one scoped with using ( (select auth.uid()) = user_id ) rather than disabling RLS again.
Can GuardLayer detect this at runtime or watch my AI agent? No. GuardLayer is a static scanner — it reads your code, migrations, and config and flags missing RLS, leaked keys, and unscoped policies on every push. It does not run your app, do data-flow analysis, inspect MCP servers, or monitor your agent at runtime. It catches the open table in the diff, before merge.
What is CVE-2025-48757? A May 2025 vulnerability rated CVSS 9.3 (Critical) in its CVE record, describing Lovable-generated apps that shipped with missing or insufficient Supabase Row Level Security. Researcher Matt Palmer found 303 endpoints across 170 production apps leaking user data via the public anon key. The root cause was a missing RLS policy per table — the same gap any agent can produce.
A table is genuinely meant to be public. Do I still need RLS on?
Yes. Keep RLS enabled and add an explicit for select ... using (true) policy scoped to select only. That makes it public for reads on purpose while still blocking the anonymous writes and deletes that a fully open table allows.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.