You turned on Row Level Security. You wrote a policy. You tested it with your own account and it worked.
Then a user emails you: "why can I see someone else's invoice?"
This is the most common false-confidence trap in vibe-coded Supabase apps. ALTER TABLE ... ENABLE ROW LEVEL SECURITY is not a switch that makes data private. It makes a table participate in RLS. Everything else — policies, views, grants, related objects — still decides who sees what. Any one gap and the data leaks while the dashboard shows a green "RLS enabled" badge.
Here are the seven surfaces I check, in order, because they're where leaks actually hide. All queries below are read-only catalog reads you can run in the Supabase SQL editor.
1. RLS enabled but zero (or empty) policies
RLS with no policy blocks everyone except service_role. That's safe-but-broken, not a leak. The dangerous version is the opposite: RLS on, with a policy so broad it's effectively off.
select schemaname, tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Read the qual column. If you see true, or a role of {public}, or {anon}, that table is readable by anyone with your anon key — which is embedded in your client bundle and visible to the world.
The one I see most: a "temporary" dev policy left in:
-- the policy that ships to prod because it "worked"
create policy "allow read" on public.profiles
for select using (true);
Fix direction: scope by ownership, not by existence.
create policy "own rows" on public.profiles
for select using (auth.uid() = user_id);
2. Cross-tenant leaks from a missing WITH CHECK on writes
USING filters what a user can read/update/delete. WITH CHECK filters what they can insert/change into. A policy with USING but no WITH CHECK lets a user read only their rows — then write a row into someone else's tenant.
select policyname, cmd, qual as using_expr, with_check
from pg_policies
where schemaname = 'public';
If with_check is null on an INSERT or UPDATE policy, an attacker with the anon key can insert rows tagged with another workspace_id/org_id.
create policy "insert own" on public.documents
for insert with check (auth.uid() = owner_id);
3. Views bypass RLS by default
This is the one that surprises people. A normal Postgres view runs with the owner's privileges and does not apply the base table's RLS. If you built a public.user_summary view for convenience, RLS on users may be irrelevant — the view quietly exposes everything.
select table_name, security_invoker
from information_schema.views v
join pg_class c on c.relname = v.table_name
left join pg_class a on a.oid = c.relowner
where table_schema = 'public';
In Postgres 15+ (Supabase runs it) the fix is one line — make the view respect the caller's RLS:
alter view public.user_summary set (security_invoker = on);
Otherwise you're relying on grants alone, which brings us to the next surface.
4. GRANT to anon/authenticated overrides intent
RLS decides which rows. Grants decide whether the role can touch the object at all. A loose grant to anon on a table with a broad policy is a double hole; a grant on a table you thought was protected is often the actual vector.
select grantee, table_name, privilege_type
from information_schema.role_table_grants
where table_schema = 'public'
and grantee in ('anon','authenticated')
order by table_name;
Look for anon + SELECT on anything containing emails, keys, or financial rows.
5. Storage buckets with public read
RLS on your tables does nothing for Storage. Buckets have their own access model. A bucket set to public serves every object to anyone who knows the URL — and object URLs are often loosely guarded in the app code.
In the dashboard: Storage → bucket → check "Public bucket". If it's on and you store user uploads there, that's your leak. Verify with a quick script hitting the public object path with the anon key — if it 200s for a file you don't own, it's open.
6. Realtime broadcasts rows RLS can't stop
Realtime starts from the anon/authenticated role and applies RLS for postgres_changes — but only if you use Row Level Security correctly and the table is in the publication. Misconfigured, a client subscribes to a table and receives change events for rows it should never see.
select * from pg_publication_tables where pubname = 'supabase_realtime';
If a sensitive table is published, confirm the subscribing user passes your SELECT policy for every row the channel delivers — not just the rows you intended to show.
7. SECURITY DEFINER functions re-open the door
A SECURITY DEFINER function runs as its owner — often a superuser-ish role that RLS doesn't restrict. If such a function (common in trigger helpers, e.g. an is_admin() check) is EXECUTE-able by anon, an attacker can call it directly and get owner-level access.
select p.proname, p.prosecdef as security_definer,
pg_get_function_identity_arguments(p.oid) as args,
r.rolname as owner
from pg_proc p
join pg_roles r on r.oid = p.proowner
where p.pronamespace = 'public'::regnamespace
and p.prosecdef = true;
Fix direction: SECURITY DEFINER functions should set search_path = '', validate auth.uid() inside, and be revoked from anon unless truly needed.
The pattern
Every one of these leaks shares a shape: RLS was enabled, so everyone assumed the work was done. The check isn't "is RLS on", it's "for each object a low-privilege role can reach, what does it actually return?"
If you want the structured version, I keep these as seven commented SQL audit files (RLS coverage, policies, write checks, grants, bypass paths, Storage, Realtime, plus a safe BEGIN...ROLLBACK probe file) with a 60-check manual pass and an optional two-user isolation harness. The catalog queries above are the first pass; the kit is the full sweep with the report/remediation templates.
More detail and the exact file list: rls.cenkkurtoglu.com
If you're mid-launch and just want a second pair of eyes before real users sign up, that's what I do — read-only review of source, schema and policies.
Top comments (0)