Supabase RLS checklist: audit your tables in plain English
Most Supabase data leaks come from two mistakes: a table without Row Level Security, or a service_role key in the browser. This checklist walks you through finding both and fixing them with SQL you can paste.
Short answer: Turn on Row Level Security for every table in the public schema, give each table policies that compare auth.uid() to the row's owner, and keep the service_role key out of your frontend. Then test as a logged-out user.
This is the broad audit. If you want a focused walkthrough of testing policies as an anonymous user, use the Supabase RLS checker. If you think your service_role key is already public, go straight to service_role key exposed.
Why these two mistakes burn Supabase apps
Supabase gives every project an API generated from your tables. Your frontend calls it with a public key: the anon JWT or the newer sb_publishable_ key. That key is in your JavaScript and anyone can copy it. Supabase says that's fine, because the key can only do what Row Level Security allows.
That's the catch. RLS is a per-table switch. With it off, the public key can read and write every row of that table through the API. Tables you create with SQL rather than the dashboard's table editor don't have RLS enabled unless you turn it on. AI builders write a lot of SQL migrations.
The second mistake is worse. The service_role key (or a new-style sb_secret_ key) bypasses RLS completely. AI-generated code sometimes reaches for it when a query "doesn't work", because it makes permission errors disappear. If it ends up in the frontend, your policies stop mattering: whoever holds that key can read or change every table.
The audit, step by step (manual)
All of this runs in the Supabase dashboard's SQL editor. Nothing here changes data until step 4.
Step 1: list tables without RLS
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Any row with rowsecurity = false is open to the public key. Write those table names down.
Step 2: list every policy
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
Read each one in plain English. qual is the rule for which existing rows a query can see or change. with_check is the rule for new or updated rows. Red flags:
qualorwith_checkis justtrueon a table with user data. That means everyone.- Roles include
publicoranonon a table that should need a login. - An update policy with a
qualbut nowith_check, so a user could changeuser_idto someone else's.
Step 3: find where the service_role key is used
Search your code for service_role, SUPABASE_SERVICE_ROLE_KEY and sb_secret_. It belongs only in Edge Functions or other server code, read from an environment secret. On your live site, open DevTools, search all sources (Ctrl+Shift+F) for eyJ, and decode each JWT's middle part. A payload with "role":"service_role" means the key is public. Rotate it in Project Settings, API, before doing anything else. The leaked API key checker shows the decode step.
Step 4: enable RLS and add owner policies
For a table where each row belongs to one user and has a user_id uuid column referencing auth.users:
alter table public.notes enable row level security;
create policy "Owners can read their notes"
on public.notes for select
to authenticated
using (auth.uid() = user_id);
create policy "Owners can add their notes"
on public.notes for insert
to authenticated
with check (auth.uid() = user_id);
create policy "Owners can update their notes"
on public.notes for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Owners can delete their notes"
on public.notes for delete
to authenticated
using (auth.uid() = user_id);
Replace notes and user_id with your table and column. Insert only takes with check; select and delete only take using; update takes both. For data everyone may read, such as a public product list, a select policy using (true) is correct. Just don't add insert, update or delete policies for anon.
For every table in the public schema: enable row level security, then create separate select, insert, update and delete policies for the authenticated role that only allow rows where auth.uid() = user_id (update needs both using and with check). Do not use service_role in any client code. Show me the SQL and the list of tables before applying it.
Step 5: test as a stranger
Log out, or use a private window, and use your app. Then sign in as a second test user and try to load the first user's data. With your anon key you can also query the API directly; a protected table should return an empty list, not rows. Rerun steps 1 and 2 after every migration.
Make it stick
- Put
enable row level securityin the same migration that creates each table. - Check the Security Advisor in the Supabase dashboard after schema changes.
- Tell your AI tool, in its rules or project instructions, never to use the service_role key in client code.
What the free scan checks here (and what it doesn't)
ShipWatch looks at your live page and up to 25 linked scripts. It flags a Supabase service_role JWT or sb_secret_ key as critical, and reports your anon or publishable key and project URL as information, with a prompt to audit RLS. It does not connect to your database, read tables or test policies; this audit is the only way to know. It also checks other keys, /.env, /.git/HEAD, headers and HTTPS, covered in the 6 mistakes guide and the Lovable security checklist.
FAQ
- If RLS is enabled with no policies, can anyone read my table?
- No. With RLS on and no policies, the anon and authenticated roles get no rows at all. The table is locked until you add policies. The service_role key still bypasses RLS, which is why it must stay server-side.
- Should I use auth.uid() or (select auth.uid()) in policies?
- Both are correct. Supabase recommends wrapping it as (select auth.uid()) on large tables because Postgres can then evaluate it once per query instead of once per row. Start with whichever is clearer to you.
- Can ShipWatch test my Supabase RLS?
- Not directly. The free scan flags a service_role or sb_secret_ key in your page or scripts and notes when your app uses Supabase, but it never reads your tables. Use the SQL audit on this page to check RLS.