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:
qualis the read filter (SELECT, UPDATE, DELETE).truemeans everyone.auth.uid() is not nullmeans every signed-in user, which for an app with open signup is also everyone.with_checkis the write filter (INSERT, UPDATE). A table that is safe to read but haswith_check (true)lets anyone insert rows with somebody else'suser_id.- Missing commands. A policy for SELECT only means INSERT, UPDATE and DELETE are denied. That is usually what you want; check it is not the other way round.
- Role. Policies for
authenticateddo nothing foranon, and vice versa. A policy onpubliccovers both. - Ownership column. The policy must compare
auth.uid()with the column your app actually writes. A policy onowner_idwhen the app fillsuser_idis a policy on a column that is always null.
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
- Storage buckets have their own policies on
storage.objects. A public bucket serves any object to anyone who guesses or is given the path. Check that uploads are scoped toauth.uid()in the path and that signed URLs expire. - Postgres functions marked
security definerrun as their owner and skip RLS. Every one of them is an API that must checkauth.uid()itself. - Edge functions receive the caller's JWT but do not verify it unless you tell them to. A
function that uses the service-role client and trusts a
user_idfrom the request body is an IDOR waiting to happen.
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.