ShipWatchFree scan

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:

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

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.

Scan your app free in 30 seconds

Paste your live URL for a plain-English report with fix prompts. If you keep shipping, weekly monitoring re-checks it: Solo $19/mo or Pro $49/mo.

Scan my app free