DEV Community

Cover image for Your Supabase RLS policy passes every test and still leaks
Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

Your Supabase RLS policy passes every test and still leaks

There is a class of Row Level Security bug that survives every signal you have. Your tests pass. Supabase's linter is happy. The dashboard shows RLS enabled with a policy attached. And a user can still read another tenant's rows.

I spent a while explaining this in comments and gave up, so I built a reproduction instead. This post is the written version.

The setup

Multi-tenant app. Users belong to organisations through a membership table. You ask for the obvious policy: a user can only read documents belonging to organisations they are a member of.

You get something like this:

create policy "members can read org documents"
on documents for select
using (
  exists (
    select 1 from memberships m
    where m.org_id = documents.org_id
  )
);
Enter fullscreen mode Exit fullscreen mode

Read it again. It looks right. It mentions memberships. It ties documents to orgs. Everything a review would look for is present.

Why it passes everything

Your tests pass. You wrote them from one account, in one organisation. That account reads its own rows and sees them. It reads a fabricated foreign row and, if your fixture only contains one org, there is nothing to leak. Green.

The linter is happy. Supabase's database linter checks for rls_disabled_in_public, policy_exists_rls_disabled, and rls_enabled_no_policy. RLS is on and a policy exists, so none of those fire. The linter evaluates presence, not correctness — which is the right scope for a linter, and exactly the gap.

Code review passes. The SQL is valid, readable, and mentions the right tables.

The bug

The exists subquery never filters by the current user. It asks "does any membership row exist for this org", not "does a membership row exist for this org and this user".

Any authenticated user satisfies it for every organisation that has at least one member.

The fix is one predicate:

using (
  exists (
    select 1 from memberships m
    where m.org_id = documents.org_id
      and m.user_id = auth.uid()
  )
);
Enter fullscreen mode Exit fullscreen mode

That is the whole difference between isolated and open.

Why single-account testing cannot catch it

This is the part worth internalising. The bug is invisible to a test suite that authenticates as one identity, because the leak requires a second tenant to exist and a second identity to read across the boundary. Coverage does not help. More assertions from the same session do not help.

The minimum test that catches it:

  1. Two organisations, each with data.
  2. Two users, each a member of a different organisation.
  3. User A authenticates and reads. Assert that User A sees only A's rows and that the count of B's rows returned is zero.

That is it. One extra identity. If your suite has that test for every table holding tenant data, this entire bug class is closed.

The variant that is worse

The same shape appears with users who legitimately belong to two organisations — shared staff, contractors, agencies managing multiple clients. There the join is correct in spirit but the policy leaks between the two orgs the user belongs to and every other org, and the user has no reason to report it because their own view looks normal.

Two other checks in the same family

Permissive versus restrictive. In Postgres, multiple permissive policies on the same table and command are combined with OR. Add a broad permissive policy next to a careful one and the broad one wins. Nothing warns you. select * from pg_policies where schemaname = 'public'; and look for more than one permissive policy on the same table and command.

Service-role key reachability. The service role bypasses RLS entirely. If that key is readable from any client-side path — a public env var, a route handler that echoes config, a client component importing a server module — then no policy audit means anything. Grep your bundle for the key prefix before you trust any of this.

Run it yourself

I put the failing and fixed versions in one repo with the same test suite on both branches. Red on broken, green on fixed. No Docker, no cloud project, about two seconds:

https://github.com/cekuu35/supabase-rls-leak-demo

git clone https://github.com/cekuu35/supabase-rls-leak-demo
cd supabase-rls-leak-demo
npm ci && npm test
Enter fullscreen mode Exit fullscreen mode

The cheapest thing you can do right now

Before any of the above, open your SQL editor and run:

select tablename, rowsecurity
from pg_tables
where schemaname = 'public';
Enter fullscreen mode Exit fullscreen mode

Any table holding user data with rowsecurity = false is the entire conversation. Fix that first, then come back to policy correctness.


I do this as paid work, so treat the rest as disclosure rather than a pitch: the ten checks I run before shipping a Next.js + Supabase app are in a free PDF, no email wall — https://cengokurtoglu.gumroad.com/l/nextjs-supabase-10-checks-free?utm_source=devto&utm_medium=article&utm_campaign=rls_review&utm_content=free_checklist

If you run the free checks and everything comes back clean, you have saved yourself an audit, and I would genuinely rather you knew that than bought one you did not need.

If one or more policies are still ambiguous after the free checks, the same audit is packaged as files you run yourself: seven commented SQL audits against your own catalogs, covering RLS coverage, permissive-over-restrictive conflicts, write-side WITH CHECK gaps, grants, bypass paths, Storage and Realtime, plus a role-simulation harness that wraps its probes in BEGIN ... ROLLBACK. A 60-check workflow and report templates come with it. It is a configuration review for projects you own or are authorized to test, not a penetration test.

Supabase RLS Audit Kit — $29 one-time

Top comments (0)