ProdGuard

Guide
Supabase · Postgres

How AI agents bypass Row Level Security without ever disabling it

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.

Mechanism one

A function that runs as its owner

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.

Mechanism two

A second policy beside the first one

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.

Mechanism three

Widening a GRANT instead of fixing the policy

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.

Mechanism four

The schema-wide grant

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.

Mechanism five

A policy with no TO clause

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.

Mechanism six

Careful reads, open writes

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.

Mechanism seven

A view in public

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 ...
The part nobody wants to hear

What none of this catches

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.

A worked example of getting this wrong

The rule that fired 15 times out of 15

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.

Checking your own project

Running these as a build gate

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