Everyone looks for DISABLE ROW LEVEL SECURITY in the diff. It is the least common way this
goes wrong. RLS usually stays on, the policies still read correctly, and access is granted around them.
Here are seven mechanisms, the SQL for each, and why every one of them survives code review.
None of this requires the agent to do anything reckless. Asked to fix a query returning no rows, it takes the shortest path to a working result. Several of these are that path.
Six of the seven below were reported by people who hit them in real projects, not invented for this page. Where a claim is contested, the Postgres documentation is cited.
A SECURITY DEFINER function executes with the privileges of whoever created it, normally
a superuser-ish role. RLS on the tables it touches does not apply inside it. It is a legitimate,
documented tool, which is exactly why it slips past.
create function public.get_all_quotes() returns setof public.quotes language sql security definer -- runs as the owner; RLS does not apply in here as $$ select * from public.quotes $$; grant execute on function public.get_all_quotes() to anon;
RLS is still enabled on quotes. Every policy is intact. Anyone holding the anon key can
now read the entire table through the function.
How to check: Supabase's own linter covers this as
0028/0029 (definer function executable by anon or
authenticated), 0010 (definer view in public) and
0011 (mutable search_path). Do not flag every definer function; the
Supabase docs recommend them for escaping policy recursion, and flagging all of them produces noise
nobody keeps.
Postgres combines permissive policies with OR. When several cover the same command, the most open one decides. Both policies read perfectly well on their own.
create policy "quotes are owned" on public.quotes for select using (auth.uid() = owner); create policy "temp debug read" on public.quotes for select using (true); -- this one wins, quietly
A reviewer reading either policy in isolation sees nothing wrong. The bug only exists in the relationship between them.
How to check: look for an always-true permissive policy that shares a command with
another policy on the same table. A lone using (true) is often a deliberately public
table and is not by itself a finding.
An agent hits permission denied. Granting is a faster fix than working out the policy,
and it touches nothing anyone is watching.
grant insert, update on table public.quotes to anon; grant all on table public.invoices to authenticated;
Nothing is disabled. No policy changes. GRANT ALL also supersedes any column-level
grants that were narrowing access, and quietly includes TRUNCATE.
The same act at schema scope, and the one most likely to be missed by a scanner, because there is no table name in the statement to match on.
grant all on all tables in schema public to anon;
One line. Covers tables the author never looked at, including ones added later by other people, and silently undoes every earlier hardening migration. RLS is the only thing left standing between the public internet and the data.
A create policy with no TO clause defaults to PUBLIC, which
includes anon. Agents leave it off constantly, because a lot of tutorials do.
create policy "published docs" on public.docs for select using (published = true); -- no TO clause: applies to anon too
Whether that actually leaks depends entirely on the predicate, and this is where most write-ups get
it wrong. A predicate gating on auth.uid() evaluates to NULL for an
anonymous caller and matches no rows, so the missing TO exposes nothing. A predicate
that ignores the caller, like the one above, returns rows to anyone.
This distinction matters more than it looks. Flagging every policy with a missing
TO clause sounds rigorous and produces a false positive on almost every Supabase project
in existence, because most policies gate on auth.uid(). See the last section.
The using expression scopes which rows the caller can see. with check
controls what they are allowed to write. When the second is unconditional, a signed-in user can move a
row to somebody else's account.
create policy docs_update on public.docs to authenticated for update using (auth.uid() = owner) with check (true);
The read side looks correct, which is why this survives review.
A correction worth making, because it is widely repeated: omitting
with check is not the bug. The
Postgres documentation
states that for UPDATE, when no WITH CHECK expression is defined, the
USING expression is used both to determine visible rows and to validate new ones. An
INSERT policy cannot have a USING expression at all, so
WITH CHECK is required there and cannot be forgotten. The hole is an
explicit with check (true), not an absent clause.
A view queries its underlying tables as whoever created it, unless security_invoker is
set. RLS on those tables does not apply to whoever selects from the view.
create view public.quote_totals as select owner, count(*) from public.quotes group by owner; -- fix: create view ... with (security_invoker = on) as ...
Two failure modes are invisible to any tool that reads your repository, and it is worth being plain about them rather than implying coverage that does not exist.
Changes that never go through code. RLS switched off by hand in the SQL editor, or a migration run straight against the database, never appears in a diff. The durable answer is a Postgres event trigger that rejects the change at the database level, whoever runs it and from wherever. That is a different thing from a repository scanner and it holds when the scanner cannot see anything.
Policies that are semantically wrong. A policy checking auth.uid()
against the wrong column after a rename reads as entirely valid SQL. Nothing static catches it. The
only reliable check is running two authenticated sessions against the same table and comparing what
each can actually read and write.
A cheap partial tripwire for the rename case: flag any policy whose predicate names a column that no migration in the repository ever creates. It will not catch a rename to another valid column, but "policy references a column that isn't there" is a common wake left by a schema change nobody re-checked RLS against.
Mechanism five is a good illustration of why detection is harder than the list above suggests.
The obvious rule is: flag every create policy with no TO clause. It is
correct in the sense that all of them do apply to PUBLIC. Run against a real project with
fifteen policies, it produced fifteen findings. Fourteen of them gated on
auth.uid(), which is NULL for an anonymous caller, so the policies matched no
rows and the missing TO leaked nothing at all.
A tool that is wrong fourteen times out of fifteen is uninstalled the same day, and the one true
finding goes with it. Narrowed to fire only when the predicate ignores the caller, the same project
produced one finding: a policy reading using (bucket_id = 'logos') with
no TO clause, genuinely readable by anyone. Deliberate in that case, but correctly
identified.
For anything in this class, the false positive rate decides whether the check is worth having. A rule that cries wolf is worse than no rule, because it trains people to ignore the output.
ProdGuard encodes all seven, plus fifteen other checks, and exits non-zero so CI fails rather than warns. MIT, no runtime dependencies.
npx prodguard check --demo # every rule against a broken example app, touches nothing npx prodguard check # your project; exit 1 on critical
It matches text rather than parsing your program, so an unusual spelling of the same bug gets past it, and a clean run means those checks did not fire, nothing more. The two failure modes in the previous section remain out of reach for it and for every other repository scanner.
ProdGuard · Did an AI agent break my app? · Check if RLS is enabled · Verify a Stripe webhook