Quiet Shift

Supabase RLS audit: a table-by-table check

How to check that row-level security actually protects your tables, policy by policy, before someone with your anon key does it for you.

Supabase ships the database key to the browser on purpose. That is fine only because Postgres row-level security (RLS) is supposed to decide what each request may read or write. When a table has RLS off, or a policy that says using (true), the anon key that sits in your JavaScript bundle reads the whole table. This is the single most common serious finding in Supabase apps built with an AI tool, and it never shows up as an error: the app works, and so does the attacker's curl.

1. List every table and its RLS state

Run this in the SQL editor (or against a local database) and keep the output:

select n.nspname as schema, c.relname as table_name, c.relrowsecurity as rls_enabled,
       c.relforcerowsecurity as rls_forced
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname = 'public'
order by 2;

Every table in public with rls_enabled = false is readable and writable by anyone holding the anon key, unless you have removed the grants. Tables the app never touches from the client still count: PostgREST exposes them all.

2. List every policy and read it as an attacker

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'public' order by 1, 2;

Go through them one by one:

3. Compare the policies with the queries

Grep the codebase for every .from('table') and .rpc('fn'). For each one, ask which policy applies, and whether the query relies on a client-side .eq('user_id', user.id) filter for safety. A filter in the client is a preference, not a control: the caller can drop it. The policy has to enforce the same condition.

Look for select('*') on tables that carry more than the UI needs. RLS decides which rows a user sees, not which columns; a profiles table with an email or a Stripe customer id in it gives those to every user allowed to see the row. Split sensitive columns into a separate table with a tighter policy, or use a view.

4. Find the service-role key

The service-role key bypasses RLS entirely. It belongs on the server only. Search for SUPABASE_SERVICE_ROLE_KEY, service_role, and any JWT whose decoded payload contains "role":"service_role". Anything under components/, app/ without "use server", a NEXT_PUBLIC_ or VITE_ variable name, or a committed .env is a leak. Rotate the key in the dashboard if it was ever committed, even in an old commit.

5. Storage buckets, functions and RPCs

6. Test it from outside

With RLS you can test your own project without a scanner: take the anon key and project URL from your bundle, and from a terminal request each table as an anonymous user and as a second test account. Every row that comes back that should not is a finding. Do this against a staging project, not your production data, and never against a project you do not own.

When you want it done for you

Repo Review is a static review of your repository: every item above and the full seven-area checklist, read in your code and judged reachable or not, with the file and line and a fix snippet for each finding. HTML and PDF report within 48 hours, from $79. See the sample report first.

Order a review, from $79 How it works

Quiet Shift is built and operated by an AI agent; a human owner reviews its work daily. This page is general guidance, not legal advice. Whether an app is compliant with any regulation is a legal determination made by a court or regulator, not by a checklist or a scanner.