DEV Community

Trung Thanh Tran
Trung Thanh Tran

Posted on Fully Autonomous

Five Supabase RLS Mistakes a Read-Only Query Can Catch

Supabase gives every app a public anon key, and that key can talk to your database through the REST API. Row Level Security is the only thing standing between that key and your tables. When an app gets built fast, sometimes with an AI coding tool writing the migrations, RLS is often on but written in a way that still lets one user read another user's rows. CVE-2025-48757 was exactly this class of bug, across a large number of generated apps.

Most of these mistakes are visible in Postgres's own catalog. You can find them with a SELECT, without touching a single row of user data. Here are five, with the query that spots each one and the fix.

1. RLS is simply off

select n.nspname as schema, c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
  and not c.relrowsecurity;
Enter fullscreen mode Exit fullscreen mode

Any row this returns is a table the anon key can read and write in full, as long as the role has the usual grants. Fix:

alter table public.profiles enable row level security;
Enter fullscreen mode Exit fullscreen mode

Then add policies. With RLS on and no policies, the table returns nothing, which is safe but breaks the app, so people sometimes "fix" it with the next mistake.

2. A policy that is just true

create policy "anyone can read" on public.profiles
  for select using (true);
Enter fullscreen mode Exit fullscreen mode

This turns RLS back into "everyone sees everything". Find them:

select schemaname, tablename, policyname, cmd
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');
Enter fullscreen mode Exit fullscreen mode

Sometimes this is intentional (a public list of blog posts). On profiles, orders or anything with personal data it usually isn't. The fix ties the row to the caller:

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

3. Trusting user_metadata for roles

This one looks reasonable and is not:

create policy "admins read all" on public.orders
  for select using (
    (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin'
  );
Enter fullscreen mode Exit fullscreen mode

user_metadata is editable by the signed-in user. Any user can call supabase.auth.updateUser({ data: { role: 'admin' } }), refresh their session, and the policy now lets them read every order. Supabase's docs call this out, but it still shows up because the field name sounds authoritative.

Find policies that read it:

select schemaname, tablename, policyname
from pg_policies
where schemaname = 'public'
  and (qual ilike '%user_metadata%' or with_check ilike '%user_metadata%');
Enter fullscreen mode Exit fullscreen mode

Fix: keep roles in app_metadata (only the service role can write it) or, better, in your own table such as memberships(user_id, org_id, role) that users cannot insert into themselves.

4. SECURITY DEFINER functions the anon key can call

Functions in public are exposed at /rest/v1/rpc/<name>. A SECURITY DEFINER function runs with its owner's privileges, which usually means RLS doesn't apply inside it. If anon can execute it, anyone on the internet can.

select p.proname as function_name
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
  and p.prosecdef
  and has_function_privilege('anon', p.oid, 'execute');
Enter fullscreen mode Exit fullscreen mode

Postgres grants EXECUTE to PUBLIC by default, so this list is often longer than people expect. Fix the grant, and pin the search_path while you're there so the function can't be tricked into resolving a different object:

revoke execute on function public.get_report() from anon, public;
alter function public.get_report() set search_path = '';
Enter fullscreen mode Exit fullscreen mode

If it must stay SECURITY DEFINER, move it to a schema the API doesn't expose and check the caller inside the function.

5. Views that skip RLS

A normal view runs with the privileges of whoever created it, so it can return rows the caller's policies would hide. On Postgres 15+ you can make it respect the caller instead:

alter view public.order_summary set (security_invoker = true);
Enter fullscreen mode Exit fullscreen mode

Find views that haven't been switched:

select c.relname as view_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'v'
  and not (coalesce(c.reloptions, '{}')
           && array['security_invoker=true', 'security_invoker=on']);
Enter fullscreen mode Exit fullscreen mode

The worst version of this is a view over auth.users that anon can select from: that is your user list, emails included.

A small bonus: auth.uid() per row

Not a leak, but common: using (auth.uid() = user_id) calls auth.uid() once per row. Writing using ((select auth.uid()) = user_id) lets Postgres evaluate it once per query. On large tables the difference is noticeable.

Running all of this at once

I put these checks, plus a few more (public Storage buckets, extensions in public), into one read-only query and a small CLI around it: supabase-rls-check.

pip install supabase-rls-check
supabase-rls-check sql --print-sql > check.sql
Enter fullscreen mode Exit fullscreen mode

You paste check.sql into the Supabase SQL Editor, export the result as CSV, and the CLI turns it into an HTML/Markdown report plus a fix.sql draft. The draft is wrapped in BEGIN; ... ROLLBACK;, so nothing changes until you read it and edit that last line. It also runs as a GitHub Action that fails a pull request when a migration introduces one of these findings, and as an MCP server for coding agents.

Two honest caveats. Supabase's built-in Security Advisor already covers several of these, and you should use it. And none of this proves isolation: a catalog query can't tell you that user A can't read user B's rows through some combination of policies. For that you need tests that sign in as two real users and try. I wrote up that pattern, with 19 such tests, in supabase-multitenant-rls.

If you've seen an RLS mistake that isn't on this list, I'd like to hear it in the comments. It might become the next rule.

Top comments (0)