DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

RLS is on, so why can another user see my data? 7 Supabase surfaces that leak anyway

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Fix direction: scope by ownership, not by existence.

create policy "own rows" on public.profiles
  for select using (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)