"RLS is enabled and there are policies on the table" is not the same sentence as "this table is protected." I spent a day reading row-level-security setups in public Supabase projects, and the gap between those two sentences is where almost every real leak lived.
Here are the two queries I run first. Paste them into the SQL editor, then read the section under each one — the output is useless without the interpretation.
Query 1 — which policies exist, and who they actually apply to
select tablename, policyname, cmd, permissive, roles,
case
when 'public' = any(roles) then 'APPLIES TO ANON'
when qual = 'true' and cmd in ('SELECT','ALL') then 'WIDE OPEN READ'
when cmd in ('UPDATE','ALL') and with_check is null then 'writes reuse USING'
else 'ok'
end as verdict,
qual, with_check
from pg_policies
where schemaname = 'public'
order by verdict, tablename;
APPLIES TO ANON is the row to care about. A policy with no TO clause defaults to PUBLIC, and PUBLIC in Postgres is not a role you can inspect — it is a keyword meaning every role, including ones created later. That includes anon, which is the role behind the key your frontend ships to every browser.
So this:
alter table dm_messages enable row level security;
create policy "read messages" on dm_messages for select using (true);
is RLS "enabled", with a policy, and readable by anybody who opens your app. In one project I looked at, that pattern exposed every message body in the product.
One caveat that makes this less scary than it sounds: RLS runs on top of the normal privilege system, not instead of it. If anon has no SELECT grant on the table, it still gets permission denied regardless of policies. revoke is the cheapest second line of defence and almost nobody uses it.
Query 2 — which tables are switched on at all
select n.nspname, c.relname, c.relkind,
c.relrowsecurity as rls_on,
c.relforcerowsecurity as forced,
c.relispartition as is_partition,
count(p.oid) as policies
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind in ('r','p','f')
group by 1,2,3,4,5,6
order by c.relrowsecurity, c.relname;
Three readings:
-
rls_on = false— the obvious hole. -
rls_on = true, policies = 0— default-deny. Not a leak, but a bug you will feel:SELECTsilently returns an empty set,UPDATE/DELETEaffect zero rows, andINSERTfails outright withnew row violates row-level security policy for table "...". People spend hours on this thinking their client is broken. -
is_partition = true— read the warning below.
Note relkind in ('r','p','f'), not just 'r'. Partitioned parents are 'p', and if you filter for 'r' alone they vanish from your audit. Worse, partitions do not inherit their parent's policies: a child's policies apply only when the child is named directly in the query, and the parent's are ignored in that case. Since Supabase exposes every table in public over the Data API, an attacker asks for orders_2026_01, not orders.
Now the part people get wrong
The single most-reported "RLS vulnerability" I ran into was this:
create policy "users update own rows"
on documents for update
using ( (select auth.uid()) = user_id );
-- no WITH CHECK
The reasoning: USING controls what you can see, WITH CHECK controls what you can write, so with no WITH CHECK a user can flip user_id to someone else's UUID and steal or dump a row.
That reasoning is wrong. For policies that can carry both expressions — ALL and UPDATE — omitting WITH CHECK does not skip the write check. Postgres reuses the USING expression for it (CREATE POLICY). The theft attempt hits:
ERROR: new row violates row-level security policy for table "documents"
(If a restrictive policy is what rejected it, the message names the policy instead.)
A related inference that also doesn't hold: "the INSERT policies in this file have WITH CHECK, so the author was careful there and forgot here." Postgres does not permit USING on an INSERT policy at all — WITH CHECK is the only legal clause. Its presence proves the grammar required it, not that anyone was paying attention.
But — and this is the part that matters
The paragraph above is only true for a table with one policy. Permissive policies are combined with OR, and that includes the implicit WITH CHECK side. So if the table also carries some forgotten admin policy:
create policy "admin all" on documents for all using (true);
then the effective write check becomes (auth.uid() = user_id) OR true, and the row theft goes straight through. Your careful policy did not get weaker; it just stopped being the only one.
If you want a rule that holds no matter how many permissive policies pile up later, it has to be restrictive — those are AND-ed, not OR-ed:
create policy "no tenant hopping" on documents
as restrictive for update to authenticated
with check ( user_id = (select auth.uid()) );
And the genuine version of the missing-WITH CHECK bug is when USING is wider than what you want to allow on write:
create policy "read published or own"
on posts for update
using ( is_public or author_id = (select auth.uid()) );
Here the implicit check inherits that breadth. You cannot un-publish someone else's post — the new row still has to satisfy the same expression — but you can set author_id to yourself on any public post. That is ownership theft, not vandalism, which is the worse of the two.
The test is one question: if USING passes, can the caller move the row somewhere it should not go? If no, there is no finding.
The bypasses that make all of the above irrelevant
This is the section I would read first if I were auditing my own project, because none of it shows up in the two queries above.
Table owners bypass RLS unless the table is explicitly set to FORCE ROW LEVEL SECURITY, and superusers or roles with the BYPASSRLS attribute bypass it always — FORCE does not stop them. In Supabase everything in public is owned by postgres. Four consequences:
1. You tested it wrong. Running select * from documents in the SQL editor proves nothing: you are the owner there. Test as the role your app actually uses:
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"<some-user-uuid>"}';
select * from documents;
rollback;
2. Views. A view created by postgres without security_invoker runs with the view owner's permissions, so it serves rows straight past the policies on the tables underneath. One such view in public cancels an otherwise perfect policy set.
select c.relname,
coalesce((select option_value from pg_options_to_table(c.reloptions)
where option_name = 'security_invoker'), 'false') as security_invoker
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('v','m');
3. SECURITY DEFINER functions. Often introduced deliberately to escape recursive policy checks on a join table — and then they run as owner, so a loose one opens everything, not just the table you had in mind.
select p.proname, p.prosecdef, pg_get_userbyid(p.proowner) as owner
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef;
4. Direct connections. If any part of your stack talks to Postgres over DATABASE_URL with the postgres user — Prisma, Drizzle, a migration runner, a cron job — RLS is simply not in the path. In the repos I read, this was far more common than any policy bug.
And the key note, since the naming has changed: it is not the key that bypasses RLS, it is the role's BYPASSRLS attribute; the key only selects the role. The current names are sb_publishable_* and sb_secret_*, with the older anon / service_role JWTs on the way out. If a secret key is reachable from a browser bundle, none of your policies matter — and while NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY is the version everyone jokes about, the ways it actually happens are quieter: a server secret passed as a prop into a Client Component and serialized into the RSC payload, or one of those direct connections above. import 'server-only' in the module that builds your admin client is a cheap guard. Note that a grep-based audit misses dynamic access like process.env[name], which Next.js does not inline.
While you're there: .gitignore should be .env* (with !.env.example if you keep one). .env alone does not cover .env.production.local, and I found real credentials committed exactly that way — including an .env.example carrying the live values instead of placeholders.
Honest limits
All of this is catalog inspection and static reading. It tells you which policies exist, who they apply to, and what can sidestep them. It does not tell you whether the predicate encodes your intended rule — proving isolation still needs two real users in different tenants exercising the actual API.
If you want a runnable version of that last part, supabase-rls-leak-demo is a minimal fixture where the same test suite fails on one branch and passes on the other, and the whole difference is one policy file. Runs on PGlite in about two seconds — no Docker, no credentials.
If the queries above turned up something, the useful next move is checking whether it repeats. Whoever wrote one policy usually wrote the rest the same afternoon.
Top comments (0)