Supabase security · 7 minute read
What your AI security reviewer structurally cannot see
CodeRabbit reviewed your pull request. Claude Code audited the auth flow. The linter is green, the security checklist passes, the migration is merged. Then a competitor with a browser console reads your entire user table in one fetch. Nothing in that chain was wrong — the chain was checking the wrong thing.
What automated review is genuinely good at
Both tools are good, and dismissing them costs you credibility. They answer these reliably and faster than a human ever will:
- Does the policy exist?
- Is row level security enabled on this table?
- Does the JWT payload carry
role = service_role? - Is that key committed to git?
- Are there obviously missing constraints or indexes?
These are syntactic and presence questions. A machine answers them consistently. That is the wrong axis for RLS isolation, which is a property of the set of policies on a table and of the history of that set.
Failure mode 1 — a permissive policy cancels a restrictive one
Both statements below are individually valid and both pass review. PostgreSQL applies permissive policies with OR, so the second re-opens every row and the first becomes decoration.
create policy "owner only" on public.orders
for select to authenticated using (auth.uid() = user_id);
-- two migrations later, for a "public sharing" feature:
create policy "allow all" on public.orders
for select to anon using (true);A linter that checks whether a policy exists reports two policies and no problem. The query that sees it is one line:
select tablename, policyname, roles, qual
from pg_policies
where tablename = 'orders'
order by tablename;Any row where roles contains anon and qual is true is the hole.
Failure mode 2 — the policy exists but RLS was never enabled
The policy is listed in the dashboard. It looks governed. Postgres enforces nothing on a table whose relrowsecurity is false, so the table is wide open while every artifact says it is protected. This is the failure mode that survives a launch, because nothing about it looks wrong.
select c.relname, c.relrowsecurity, count(p.polname) as policies
from pg_class c
left join pg_policy p on p.polrelid = c.oid
where c.relnamespace = 'public'::regnamespace and c.relkind = 'r'
group by 1, 2
order by c.relrowsecurity, c.relname;Any row with relrowsecurity = false and policies > 0 is a table that appears secured and is not.
Failure mode 3 — an earlier revoke makes the policy irrelevant
Policy governs rows. GRANT governs whether the role may touch the table at all. If a role holds SELECT on a table but the policy’s TO authenticated never matched it, reads work and the ownership filter never runs. Automated review checks the policy. Nobody checks the grant matrix against it.
Failure mode 4 — the fix drifts back open over months
This is the one that is invisible to any single-moment check. A fix that was correct in March becomes wrong in October when a later migration adds a second policy, or a grant changes, or a function’s search_path is cleared by a schema reset. The October state and the March state are both individually valid. Only the history shows the drift.
The 30-second version
Run these against your own database. Read-only, no writes.
select tablename, policyname, qual, with_check
from pg_policies
where roles::text like '%anon%'
order by tablename;If the first returns a row with qual = true, you have a readable table.
select c.relname, c.relrowsecurity, count(p.polname)
from pg_class c
left join pg_policy p on p.polrelid = c.oid
where c.relnamespace = 'public'::regnamespace and c.relkind = 'r'
group by 1, 2 having c.relrowsecurity = false;If the second returns anything, you have a table that appears governed and is not.
What a human review actually adds
Not “we also look at RLS.” Specifically, the property that a policy set is closed: that no policy in the set re-opens what another closes, that the grant matrix agrees with the policy matrix, and that the history of both did not undo the fix.
That check takes an afternoon on a normalized schema, and it is the check nobody has automated yet, because it requires reasoning about the set rather than the statement.
Honest scope — read this before you quote me
What I did not check, so you do not over-read this: runtime behaviour, your application’s route-layer authorisation, API route handlers, and anything outside the public schema. A policy review is not a penetration test and not a certification.
If the two queries above come back clean, you are done and you have spent nothing. If they do not, the Supabase RLS Re-Scan is a fixed $99 and under 48 hours: every table and policy as it stands today, not as it stood when you shipped, with verified / re-opened / unchanged per table and the fix SQL for anything that came back. If all of it holds, the note says so and that is the deliverable.
Free either way: the reproduction fixture at github.com/cekuu35/supabase-rls-leak-demo — one test file, red on the broken branch, green on the fixed branch, about two seconds, no Docker and no Supabase account.