Lovable, Bolt, Cursor and v0 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 standing between that key and your users' data is Row Level Security. Here's how to check it in about ten minutes.
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 that 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). Now add only the access you need.
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", which is 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);
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.
The service_role key ignores RLS entirely. It must only live in server code (API routes, server actions, edge functions). Search your code for SERVICE_ROLE and check none of it is in a 'use client' file, a NEXT_PUBLIC_ / VITE_ variable, or committed to git. If it ever was, rotate it.
A common AI-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 in server code (so RLS applies), or add .eq('user_id', user.id) to every query.
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().
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; make sure they check auth.uid() themselves.
npx github:beingRealFrank/leakproofWant someone to go through all of this, plus your live project, and send fix PRs? See the fixed-price audits.
More checklists: Firebase rules · Secret keys in your frontend