Multi-tenant security in Supabase: how tenant data actually leaks.
What "multi-tenant" means here
Most SaaS apps are multi-tenant: many customers (tenants) share one database, and each must see only their own data. The entire security model rests on one invariant — tenant A can never read or write tenant B's rows. In a Supabase app, that invariant is enforced almost entirely by Row-Level Security, because the client talks to the database directly using a public key.
That last point is what surprises people: the Supabase anon key is public by design. It ships in your frontend. It is not a secret and it is not supposed to be. Everything that stops a stranger — or a logged-in customer — from reading the whole table is the RLS policy sitting behind that key. If the policy is wrong, the public key becomes a master key.
The five ways tenant data leaks
1. The "authenticated" trap
By far the most common. A policy checks that the user is logged in, not that the row is theirs:
-- Any logged-in user reads EVERY tenant's rows.
CREATE POLICY "read" ON documents
FOR SELECT USING (auth.role() = 'authenticated');It looks like security — there's a policy, it mentions auth — and it passes most automated checks. But auth.role() = 'authenticated' is true for every signed-in user. The fix is to scope by owner:
CREATE POLICY "read own" ON documents
FOR SELECT USING (tenant_id = auth.uid());
-- or: USING (tenant_id = (SELECT tenant_id FROM members WHERE user_id = auth.uid()))2. RLS never turned on
A table in an API-exposed schema with RLS disabled is readable by anyone with the anon key — no login required. This happens when a table is added later and the "enable RLS" step is missed. A static check catches the toggle being off; it can't catch a policy that's on but wrong (that's #1).
3. The service_role key on a client path
The service_role key bypasses RLS entirely — that's its job, for trusted server code. The leak happens when it ends up somewhere the client can reach: a public env var, a client component, or a module a client component imports. Now the browser holds a key that ignores every policy you wrote.
4. SECURITY DEFINER functions with no caller check
A Postgres function marked SECURITY DEFINER runs with the definer's privileges and bypasses RLS. If it's callable over RPC and filters only by a parameter the caller supplies — not by auth.uid() — any user can pass any tenant's ID and act on their data.
5. App-layer authorization gaps (IDOR)
Even with perfect RLS, the boundary can leak above the database. A Next.js Server Action or route handler that uses the service role, or that trusts a resource ID from the request without checking ownership, is an Insecure Direct Object Reference: change the ID in the request, get someone else's data. RLS protects the database; it does not protect a route that has already bypassed it.
Four of these five are logic flaws, not syntax flaws. That's why scanners miss them and why "we ran a scan" is not the same as "we know our tenants are isolated."
Why AI-generated apps are especially exposed
Apps built quickly with Cursor, Claude, Lovable, Bolt, or v0 tend to get a working feature first and the policy later — and the generated policy is very often the "authenticated" trap from #1, because it makes the demo work. The app functions perfectly for its author, who only ever tests as one user. The leak is invisible until a second tenant exists — which is to say, until you have customers.
How to actually test that your boundary holds
You cannot confirm isolation by reading one policy. You confirm it by trying to cross the boundary:
- Create two real tenants with separate data — two accounts, exactly like two customers.
- Sign in as tenant A and request tenant B's rows directly — through the REST API, through each route, through every RPC function.
- Check writes too, not just reads: can A update, delete, or reassign B's rows?
- The result is the evidence. If a request returns a row it shouldn't, that returned row is your finding — not a warning that one "might" exist.
This cross-tenant test is exactly what Harvey's live pen-test automates: it stands up a copy of your stack, seeds two tenants, and runs the read-and-write matrix across your REST and GraphQL APIs and your app routes, then probes the paths a matrix alone misses — anon-callable privileged functions, storage objects, invitations and share links, and data left behind after a delete. A source scan flags the suspicious policy; the live test proves whether it actually leaks. Whatever the run could not reach is listed as unprobed, with the reason.
A quick self-audit
- Does every private table have RLS enabled and a policy scoped to the owner?
- Does any policy use
auth.role() = 'authenticated'orUSING (true)on private data? - Is the
service_rolekey provably absent from anything the browser loads? - Do your
SECURITY DEFINERfunctions constrain byauth.uid()? - Does every mutating route/Server Action verify the resource belongs to the caller?
- Have you tested all of the above as a second user, not just as yourself?
Walk the interactive version on the Supabase security checklist, or read how the full audit works on The Audit.
Find out if your tenants are actually isolated.
The free scan flags the risky policies on your source. The Full audit proves — live — whether one tenant can read another.