If you built your app with Lovable, Bolt or a similar tool on top of Supabase, it probably works. Users sign up, data saves, the dashboard looks right. That is not the same as the data being safe, and the gap between the two is where most of these apps sit right now.
This is not hypothetical. In March 2025 Matt Palmer reported that Lovable-generated applications were shipping without working Row Level Security. It became CVE-2025-48757: 170+ affected applications, 303 vulnerable endpoints, exposing emails, phone numbers, payment and subscription data, and third-party API keys — some with write access, meaning payment records could be modified and not merely read.
Lovable's answer, shipped in 2.0, was a built-in security scanner. That scanner checks whether RLS is enabled on your tables. It does not check whether your policies do anything. Every pattern below leaves RLS enabled and shows green.
None of this is a criticism of the tools. They generate working code quickly, and RLS is the one part of Supabase that cannot be inferred from "build me an app where each company sees its own projects". It has to be written deliberately, table by table, and the generator has no way of knowing where your tenant boundary really is.
Below are the eight failure modes I see most often, ordered roughly by how badly they end. Every one can be checked from the Supabase SQL editor in a couple of minutes. Run them on your own project.
- The user's role lives in user metadata
This is the worst one, and it is common because it is the path of least resistance.
Supabase gives every user a user_metadata object. It is convenient: you store role: "admin" or org_id there, and your policies read it out of the JWT. The problem is that user_metadata is writable by the user. Any logged-in user can open the browser console and run:
js
await supabase.auth.updateUser({ data: { role: 'admin' } })
and they are now whatever they said they were. If your policies trust that field, you do not have access control, you have a suggestion.
Check:
sql
select tablename, policyname, qual, with_check
from pg_policies
where qual ilike '%user_meta%' or with_check ilike '%user_meta%'
or qual ilike '%raw_user_meta%' or with_check ilike '%raw_user_meta%';
Any row returned is a critical finding.
Fix: roles and organisation membership belong in their own table, keyed by auth.uid(), with an RLS policy that lets users read their own membership and nobody write it from the client. app_metadata is the other option — it is not user-writable — but a table is easier to reason about and to audit later.
- RLS is simply off on some tables
Every table in the public schema is exposed through the REST API. If RLS is not enabled on it, the anon key — the one sitting in your frontend bundle, visible to anyone who opens devtools — reads the whole table.
Check:
sql
select c.relname, c.relrowsecurity
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by c.relrowsecurity, c.relname;
Everything with relrowsecurity = false is public. Supabase's dashboard warns about this, but the warning is easy to dismiss when you are shipping.
- The policy exists and does not constrain anything
This is the one the scanners cannot see, because structurally these are real policies.
The blunt version appears when someone hits a permission error during development and asks an AI to fix it:
sql
create policy "enable read for authenticated"
on public.orders for select
to authenticated
using (true);
That fixes the error perfectly. Every logged-in user now reads every order in the system. Worth noting that with anonymous sign-ins enabled — one toggle in the Auth settings — authenticated stops being a meaningful boundary at all, and to authenticated becomes decoration.
The subtle version is more interesting, and I find it more often:
sql
create policy "members can read"
on public.orders for select
to authenticated
using (
exists (
select 1 from public.memberships m
where m.user_id = auth.uid()
)
);
Read it out loud: allow if there exists a membership belonging to me. That is the entire condition. It never mentions orders. It never correlates the membership to the row being filtered. So for every row in the table Postgres asks the same question — does this user belong to any organisation at all — gets true, and returns the row.
Sign up, create your own free workspace, and you have read access to every order of every customer. No exploit chain. Just select * from orders.
The fix is one join back to the row:
sql
using (
exists (
select 1 from public.memberships m
where m.user_id = (select auth.uid())
and m.tenant_id = orders.tenant_id -- the line that was missing
)
)
The (select auth.uid()) wrapper is not cosmetic either: it lets the planner evaluate the function once per query instead of once per row. On a table of any size that is the difference between a policy that stays and a policy that gets ripped out for being slow.
Check — find the blunt ones automatically, then read the rest by hand:
sql
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual in ('true') or with_check in ('true') or qual is null)
order by tablename;
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
For the second query there is no shortcut. You are looking for conditions that do not mention the protected table.
- RLS is on, no policy exists, and someone reached for the service role
Enabling RLS without writing a policy denies everything. The table returns zero rows, the feature breaks, and the fastest way to make it work again is to call Supabase with the service role key instead of the anon key.
The service role bypasses RLS entirely. If that key is anywhere the browser can reach it, every protection in your database is off for anyone who finds it.
Check: search your repository for service_role, SUPABASE_SERVICE_ROLE_KEY and eyJ. The VITE_ / NEXT_PUBLIC_ prefix rules do protect you for environment variables — Vite strips unprefixed vars, Next.js replaces them with undefined in client code. What the prefix does not protect you from is a key written directly into source, or a module holding the key being imported by a client component. Build the frontend and grep the built bundle:
bash
npm run build && grep -r "service_role" dist/ build/ .next/ 2>/dev/null
Any JWT you find, paste into a decoder — the payload is base64, not encrypted — and look at the role claim.
Then check history:
bash
git log -p -S 'service_role' | head -50
A key that was ever committed is compromised even if it was removed later. Rotate it. There is no "probably nobody noticed" — public repositories get scraped continuously.
- Policies with no WITH CHECK
A SELECT policy controls what a user can read. INSERT and UPDATE need WITH CHECK to control what they can write. USING decides which rows you may target; WITH CHECK decides what the row is allowed to look like afterwards.
Without it, a user can update a row and set org_id to someone else's organisation — moving their own record into your other customer's account, or pulling a record out of it.
Check:
sql
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and cmd in ('INSERT','UPDATE','ALL')
and with_check is null;
FOR ALL policies are the usual suspects — they look complete and are missing half the protection.
- Views and definer functions quietly bypass RLS
A view runs with the privileges of its owner, and in Supabase that owner is usually postgres. So a view over a protected table returns everything, regardless of the policies on the table underneath. Postgres 15 added security_invoker to fix this, and it is off by default.
Check:
sql
select c.relname, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v';
If reloptions does not contain security_invoker=true, the view is a hole around your policies.
sql
alter view public.my_view set (security_invoker = true);
Materialized views do not support security_invoker at all. If one holds tenant data, it does not belong in an API-exposed schema.
The same applies to SECURITY DEFINER functions, which are exposed as /rest/v1/rpc/. They ignore RLS by design. Everyone building multi-tenant on Supabase ends up writing at least one, because a policy on memberships that queries memberships throws infinite recursion detected in policy and a definer function is the standard escape.
That is fine — until the function takes a tenant id as an argument and never checks that the caller belongs to it:
sql
create function public.get_tenant_orders(p_tenant_id uuid)
returns setof public.orders
language sql
security definer
as $$
select * from public.orders where tenant_id = p_tenant_id;
$$;
Callable over REST by any authenticated user, runs outside RLS by definition. Your policies are irrelevant; there is a documented endpoint that ignores them.
Two things to verify on every definer function — that authorisation is checked inside the body, and that search_path is pinned, since an unpinned one lets anyone who can create objects in an earlier schema hijack what the function calls:
sql
select n.nspname, p.proname, p.proconfig
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prosecdef
and (p.proconfig is null
or not exists (select 1 from unnest(p.proconfig) c where c like 'search_path=%'));
Fix with set search_path = '' and fully-qualified names in the body.
- Storage buckets that are public, and signed URLs that never expire
Files are the part everyone forgets. A bucket marked public serves every object in it to anyone with the URL — and object paths are guessable far more often than people think.
Check:
sql
select id, name, public from storage.buckets;
Then look at your createSignedUrl(path, expiresIn) calls. An hour is reasonable. A year is a permanent public link with extra steps. An individual signed URL cannot be revoked before it expires; the only lever is rotating the project's JWT secret, which invalidates every signed URL you have ever issued at once. Plan the expiry as if you will never get to take it back.
The pattern that works: private bucket, object paths beginning with the organisation id, and a policy on storage.objects comparing that first path segment against the caller's membership — (storage.foldername(name))[1].
- Edge Functions that trust their caller
Edge Functions are where the service role legitimately lives. That makes them the most dangerous thing in the project when they do not check who is calling.
Two questions for every function. Does it verify the caller's JWT — verify_jwt in config.toml, not set to false? And having identified the caller, does it check that this specific user is allowed to touch this specific organisation's data, rather than doing what the request body says?
A function that takes { org_id, action } from the request and executes it with the service role is a public admin API. It does not matter that your UI only ever sends the right org_id.
The ten-minute version
If you only do one thing, do this. Open the SQL editor and run the queries from sections 1, 2, 3 and 7 — user metadata in policies, tables without RLS, policies that do not constrain, public buckets. Four queries, four minutes. They catch the majority of what actually goes wrong.
Then do the one test that no query can replace. Create two accounts in two different organisations, take the access token of the first, and try to read the second one's data through the REST API:
bash
curl "https://.supabase.co/rest/v1/
-H "apikey: " \
-H "Authorization: Bearer "
Count the rows. Check who they belong to. Reading your policies tells you what you meant; this tells you what you built.
Do the write side too — try to insert a row carrying another organisation's id, and try to update one of your rows into theirs. A read-only test misses section 5 entirely.
One thing not to do
Do not run any of this against somebody else's application. Reading a public JavaScript bundle is one thing; querying a database you were not given access to is unauthorised access in most jurisdictions, and "I was going to tell them" is not a defence. Test what is yours, or what you have written permission to test.
I audit multi-tenant Supabase setups — RLS, storage, Edge Functions and key exposure — and deliver a written report with reproduction steps and an estimate of the work to fix each finding. Written and asynchronous, no calls.
The full checklist I work from is open on GitHub: github.com/Obi1Kanoobie/supabase-multitenant-security-checklist
If you want a second pair of eyes on your project before your first real customer logs in: tele@duck.com
Top comments (4)
Solid list - and the CVE-2025-48757 opening is the right framing, because it's this exact failure mode that the Lovable 2.0 scanner still can't see: it checks
relrowsecurityon/off, not whether the policy constrains anything.Adding a ninth one I find in these apps more often than the ones that check green as leaked: the unisolated membership join.
profileshas RLS, the join table has RLS, but the app readsuser_organizationsby filtering after the join in the client instead of in the policy:Every tenant filter that lives in the client (
.eq('org_id', userOrgId)) instead of in the policy is one file-edit away from a cross-tenant read. The reason it leaks silently: the policy layer looks healthy, and the scanner reports green. The check that catches it is a policy audit per table -org_idmust be constrained inside the policy, never trusted from the query.Free harness if useful (runs locally, only needs your schema dump): github.com/cekuu35/supabase-rls-leak-demo
yeah, client-side tenant filters are the one that scares me most, because
the policy dump looks fine and nothing shows up as broken.
the related trap i keep hitting: the policy does the right thing and
subqueries into the membership table, but then the membership table itself
has a permissive policy. so the check is only as strong as the table it
reads, and people almost never audit that one because it "has RLS".
haven't run your harness, will look at the code first
Agreed, and that nested case is the one that hides best. The data-table policy reads correctly, the membership table “has RLS” so it clears the checkbox, and then its own policy is
USING (true)because it’s “just a lookup” — so the scoped policy is only as tight as the loosest table it dereferences. Two things that catch it: (1) for every table named inside a policy’sUSING/CHECKexpression, check that table’s own policies for the caller, not merely that RLS is enabled — grep the dump forUSING (true)on lookup/roles/membership tables first, they’re almost always the ones; (2) if that membership table is anon-readable, the attacker never has to defeat the outer policy at all — they read the whole membership map and the “scoped” result is leaked by inference. The harness is two-account, so this surfaces there as a cross-tenant read rather than a 42501. Curious what you find when you run it — no rush.Thanks! Both right, and the second one is the bigger of the two — if the membership table is broadly readable, the attacker doesn't need to defeat anything. The map of who belongs to which tenant is itself the leak, usually with emails and user ids attached.
On the first I'd split it in two, because "only as tight as the loosest table
it dereferences" holds for one shape and not the other.
-- delegating: your rule applies literally
using (tenant_id in (select tenant_id from memberships))
-- self-scoping: a wide-open memberships doesn't break this
using (exists (select 1 from memberships m
where m.user_id = (select auth.uid())
and m.tenant_id = orders.tenant_id))
The first trusts the inner table's RLS to limit the rows it sees. the second
carries its own predicate, so visibility of other people's membership rows
doesn't change the outcome. the grep is still worth doing — but what it tells
you depends on which of those two you find on the outer policy, and there's a third composition that's more common than either, because it's what people reach for when the policy on memberships queries memberships and throws infinite recursion:
create function public.user_tenants() returns setof uuid
language sql security definer as $$
select tenant_id from public.memberships where user_id = auth.uid();
$$;
that runs outside RLS by definition, so the inner table's policies stop
mattering entirely and the whole check moves into the function body. worth
pulling every definer function and reading it, since a wrong one there is
invisible from pg_policies. So to make the first part mechanical rather than a grep — approximate, since it matches on names:
with loose as (
select tablename from pg_policies
where schemaname = 'public'
and (qual = 'true' or qual is null or with_check = 'true')
)
select p.tablename as protected, p.policyname,
l.tablename as referenced_loose_table, p.qual
from pg_policies p cross join loose l
where p.schemaname = 'public'
and p.tablename <> l.tablename
and p.qual like '%' || l.tablename || '%';
and for your second point, the grant side rather than the policy side:
select c.relname, c.relrowsecurity,
has_table_privilege('anon', c.oid, 'select') as anon,
has_table_privilege('authenticated', c.oid, 'select') as authed
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by anon desc, c.relname;
RLS can't save a table the role was never supposed to reach. and you're right
that none of this shows up as a 42501 — a too-loose policy returns rows, which
is why the two-account read is the only test that settles it.