AI app builders make Supabase apps fast. They also make it easy to ship a database anyone can read.
Your frontend holds the anon key, and that's by design: anyone can see it. The only thing between that key and your users' data is Row Level Security (RLS). Here's how to check yours in about ten minutes.
1. Find tables with RLS off
Run this in the Supabase SQL editor:
select schemaname, tablename from pg_tables where schemaname = 'public' and rowsecurity = false; Every row returned is a table the anon key can read and write through the REST API, whatever your UI shows. Fix:
alter table public.<table> enable row level security; With RLS on and no policies, nobody can access the table except your server using the service_role key. Then add only the access you need.
2. Find policies that let everyone in
select tablename, policyname, cmd, roles, qual, with_check from pg_policies where schemaname = 'public' and (qual = 'true' or with_check = 'true'); using (true) on a select policy means "anyone can read every row". That's fine for a product catalog and a leak for anything per-user. On insert/update/delete/all it means anyone can change anyone's data. The usual fix is an ownership check:
drop policy "<name>" on public.todos; create policy "owners only" on public.todos for all to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); 3. Prove it from the outside
Policies are easy to misread. Test with the same key an attacker has (your anon key, from your site's JS):
curl "https://<project>.supabase.co/rest/v1/<table>?select=*&limit=5" \ -H "apikey: <anon key>" Signed out, this should return [] or an error for any private table. Then sign in as test user A and confirm you can't see user B's rows.
4. Keep the service_role key out of the browser
The service_role key ignores RLS entirely. It belongs only in server code (API routes, server actions, edge functions). Search your code for SERVICE_ROLE and make sure none of it is in a 'use client' file, a NEXT_PUBLIC_ / VITE_ variable, or committed to git. If it ever was, rotate it.
5. Watch for the service_role shortcut in server actions
A common generated pattern: a server action creates a service_role client "to make it work", then updates a row by id. RLS no longer applies, so any signed-in user can change any row by sending a different id. Use the user's own Supabase client on the server (so RLS applies), or add .eq('user_id', user.id) to every query.
6. Check storage buckets
select id, public from storage.buckets; A public bucket serves any file to anyone with its URL, with no policy check. Fine for avatars, not for invoices, uploads or exports. Make those private and use createSignedUrl().
7. Check views and functions
Views run with their creator's permissions by default, so a view over a protected table can leak it. On Postgres 15+ use create view ... with (security_invoker = true). Functions marked security definer also bypass RLS, so make sure they check auth.uid() themselves.
Automate the repo side. I made a free, MIT-licensed scanner, Leakproof, that reads your migrations and code and flags tables without RLS, allow-all policies, service_role keys in client code, secrets behind public env prefixes, public buckets and unauthenticated server actions. It runs locally with nothing uploaded:
npx github:beingRealFrank/leakproof Disclosure: I also offer fixed-price audits of AI-built apps (AI agents do the review, I check every finding). The checklist and scanner are free either way.

