The Supabase RLS Checklist: Lock Down Your Tables Before Launch
Your Supabase anon key lives in the browser. That's fine, as long as Row Level Security (RLS) is doing its job. Here's a calm, step-by-step list to make sure it is, before strangers start clicking around.
Not affiliated with Supabase. Menu names and defaults change, so check their current docs if something looks different.
TL;DR
RLS on for every table in an exposed schema. Grants only for what each role really needs. One policy per operation, with
WITH CHECKon writes. Storage buckets get their own policies. Then actually test it: signed out, and signed in as a different user.
[ ] RLS enabled on every table in public (and any other exposed schema) [ ] Grants for anon / authenticated trimmed to what the app needs [ ] Separate policies for SELECT, INSERT, UPDATE, DELETE [ ] Every policy names its role (to authenticated / to anon) [ ] INSERT and UPDATE policies use WITH CHECK [ ] Users can't change owner columns or role/plan fields [ ] Views and SECURITY DEFINER functions reviewed [ ] Storage bucket policies set [ ] Columns used in policies are indexed [ ] Tested as anon and as a second user (allow + deny cases) [ ] service_role / sb_secret_ key is server-only
1. Turn RLS on for every exposed table
Supabase's docs are blunt about this: a table in an exposed schema without RLS can be read and written by any role that has a grant on it. With the anon key sitting in your frontend, that can mean "anyone on the internet."
Tables made in the dashboard Table Editor get RLS on by default. Tables created by SQL, migrations, or your AI assistant may not. Find the stragglers:
-- Tables in public with RLS still off select tablename from pg_tables where schemaname = 'public' and rowsecurity = false; -- Fix one alter table public.todos enable row level security;
RLS on with no policies means nobody (except service_role) can touch the table. That's safe, just maybe not what your app wants yet. Steps 3 and 4 fix that.
2. Trim the grants, not just the policies
Two layers protect each table. Grants decide whether a role (anon, authenticated) can reach the table at all. RLS policies decide which rows. Adding policies doesn't remove grants, so if signed-out visitors should never touch a table, take the grant away too:
revoke all on public.reports from anon, authenticated; grant select, insert, update, delete on public.reports to authenticated;
Handy debugging tip from the docs: a 42501 permission error means a grant is missing, and it fires before any policy runs. Check grants before you loosen a policy.
3. Write one policy per operation, and name the role
A single for all policy is easy to get subtly wrong. Separate SELECT, INSERT, UPDATE and DELETE policies are easier to read and test. Always add to authenticated (or to anon) so the policy doesn't quietly apply to everyone.
create policy "Read own todos" on public.todos for select to authenticated using ((select auth.uid()) = user_id); create policy "Delete own todos" on public.todos for delete to authenticated using ((select auth.uid()) = user_id);
Watch for using (true). It means "every row, for everyone this policy applies to." Sometimes that's right (a public blog posts table). Often it's an AI shortcut to make an error go away.
4. Use WITH CHECK on inserts and updates
USING filters which existing rows you can see or touch. WITH CHECK validates the row you're writing. Without it on UPDATE, a user could edit their own row and hand it to someone else by changing user_id.
create policy "Insert own todos" on public.todos for insert to authenticated with check ((select auth.uid()) = user_id); create policy "Update own todos" on public.todos for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
5. Protect the fields users shouldn't edit
RLS works on rows, not columns. If a profiles row has is_admin, plan or credits, an "update own profile" policy lets users update those too.
- Move sensitive fields to a separate table that users can only read.
- Or restrict column-level UPDATE grants so only safe columns are writable.
- Change plan, role or credits only from a server or edge function after checking who's asking.
6. Check views and SECURITY DEFINER functions
A SECURITY DEFINER function runs with its owner's privileges, and if that owner is postgres (or any role that bypasses RLS), the queries inside it skip your policies. By default, views also run with their creator's permissions. Both are classic ways to accidentally route around your policies.
- Only grant
EXECUTEon functions to roles that need them. - Read every SECURITY DEFINER function and make sure it checks
auth.uid()itself. - For views on Postgres 15+, create them
with (security_invoker = true)so the caller's RLS applies.
7. Don't forget Storage
Files in Supabase Storage are protected by policies on storage.objects, set up separately from your tables. A public bucket means anyone with the URL can download files. For things like invoices or user uploads, use a private bucket with policies that check the owner, and signed URLs.
8. Index the columns your policies use
Policies act like a hidden where clause on every query. Supabase recommends indexing the columns they filter on and wrapping functions as (select auth.uid()) so Postgres can evaluate them once instead of per row.
create index if not exists todos_user_id_idx on public.todos (user_id);
9. Test it like a stranger would
Policies that look right can still leak. The only way to know is to try the things that should fail:
- Signed out: can you read or write anything you shouldn't?
- Signed in as user B: can you read, edit or delete user A's rows?
- Can you insert a row with someone else's
user_id? - Can you update your own
planoris_admin?
Supabase supports database tests with pgTAP via supabase test db, so you can lock these checks in and re-run them after every change.
begin;
select plan(1);
set local role authenticated;
select set_config('request.jwt.claims', '{"sub":"<user-b-uuid>","role":"authenticated"}', true);
select is_empty(
$$ select * from public.todos where user_id = '<user-a-uuid>' $$,
'User B cannot see User A todos'
);
select * from finish();
rollback;10. Keep the service_role key out of the browser
The service_role / sb_secret_ key bypasses RLS completely, so none of the above matters if it's in your frontend bundle. If you find it there, follow our calm guide to rotating it. And when an AI assistant suggests the service_role key "to fix a permissions error," fix the policy instead.
Copy-paste prompt for Lovable, Bolt, Cursor or Claude
Audit this project's Supabase Row Level Security. For every table in the public schema: make sure RLS is enabled, revoke grants anon/authenticated don't need, and replace any "for all" or "using (true)" policies with separate SELECT, INSERT, UPDATE and DELETE policies scoped "to authenticated" that check (select auth.uid()) = user_id. Add WITH CHECK to insert and update policies. Make sure users can't change ownership, role, plan or credit columns. Review SECURITY DEFINER functions and views. Add indexes on policy columns. Write pgTAP tests that prove a signed-out user and a second user cannot read or modify another user's rows. Never use the service_role key in client code. Output the SQL as a migration and explain each change.
What ChillScan can and can't see
Honest note: ChillScan looks at what your site publicly serves. It can't read your RLS policies, so this checklist is yours to run. What it does catch is the stuff that makes RLS pointless, like a service_role or sb_secret_ key in your JavaScript, plus missing security headers, HTTPS issues, SEO gaps and slow pages.
RLS sorted? Scan the rest
Paste your URL and the free scan checks your public code for leaked keys, plus headers, HTTPS, SEO and speed. No signup. The $12 deep scan gives you every finding with explanations and fix tips, plus one prompt you can paste into Lovable, Cursor or Claude to fix it all.
Built on Lovable + Supabase? Lovable security checklist →