AI builders like Lovable, Bolt, Cursor and v0 can wire a Supabase backend into your app in minutes. That's great, but the same few misunderstandings keep showing up in the apps they generate. None of them are exotic. They're the kind of thing that's easy to miss when the app "just works".
Here's the checklist I run through on a Supabase app before calling it done.
The publishable key (sb_publishable_..., or the legacy anon key) is designed to live in your frontend. Anyone who opens dev tools can see it, and that's expected. What actually protects your data is Row Level Security (RLS).
Find tables in the public schema that don't have RLS turned on:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Anything with rowsecurity = false can be read, changed and deleted by anyone holding the public key, as long as the table's grants allow it. On older projects they do by default: every new table in public is granted to anon and authenticated automatically. Newer projects (and new tables in existing projects from 30 Oct 2026) no longer get those automatic grants, but when an AI tool hits a permission denied error, its fix is often grant all on ... to anon, which puts you right back. See who can reach what:
select table_name, grantee, string_agg(privilege_type, ', ') as privileges
from information_schema.role_table_grants
where table_schema = 'public' and grantee in ('anon', 'authenticated')
group by table_name, grantee
order by table_name;
The Security Advisor in the Supabase dashboard flags tables with RLS off too.
Tables you create in the Table Editor get RLS switched on by default, but tables created with raw SQL (which is what AI tools often generate) don't. Turn it on explicitly:
alter table public.todos enable row level security;
Careful on a live app: RLS with no policies blocks everyone using the public key, including your logged-in users. Reads come back empty and writes fail until you add policies. Add the policies from section 2 at the same time (or test on a branch or staging project first). Server code using the secret key isn't affected.
Once RLS is on, read every policy:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
Red flags:
qual showing true on a SELECT policy (that's using (true)) means everyone can read every row. roles showing {anon} or {public} on a table that holds user data. to ... clause, and it includes logged-out visitors.with_check showing INSERT or UPDATE policy, or an user_id to auth.uid(), so a user can write rows in someone else's name.
A typical "users only see their own rows" setup looks like this (it assumes a user_id uuid column holding the owner's auth user id):
create policy "Read own todos"
on public.todos for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Insert own todos"
on public.todos for insert
to authenticated
with check ((select auth.uid()) = user_id);
The dashboard runs queries as a privileged role, so it won't show you what a stranger sees. Ask the REST API directly with only the anon key:
curl "https://<project-ref>.supabase.co/rest/v1/<table>?select=*&limit=1" \
-H "apikey: <publishable or anon key>"
It's a read-only GET, so it can't change anything. If rows come back that shouldn't be public, your policies need work. For a private table, an empty array [] (or a permission denied error) is what you want.
One catch: when RLS blocks a read you don't get a 401. You get 200 with [], which is exactly what an empty table returns too. So test against a table that actually has data in it.
Also worth knowing: table names aren't secret. Supabase stopped serving the API's OpenAPI description to the public key in 2026, but your names are still sitting in your JavaScript (.from('todos')), so don't rely on nobody guessing them.
The service_role key (or the newer sb_secret_... key) bypasses RLS completely. If it ends up in your frontend bundle, none of the policies above matter.
You can't just grep your build for the word "service_role", because the legacy key is a JWT and its payload is base64-encoded. Decode the JWTs in your build output instead. This prints only the decoded middle part of each token, never the signature:
npm run build
grep -rhoE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+' dist/ | sort -u |
node -e 'require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean)
.forEach(t => console.log(Buffer.from(t.split(".")[1], "base64url").toString()))'
(It uses Node rather than base64 -d because JWTs drop the base64 padding, and some base64 versions choke on that or cut off the end.)
Every payload should say "role":"anon". If you see "role":"service_role", treat the key as leaked. Remove it from the frontend first, then create a new secret key under Settings > API Keys, switch your server code to it, and deactivate the legacy keys once nothing uses them. Note that deactivating the legacy keys switches off the anon key too, so move your frontend to the publishable key before you flip that switch. Whatever needed the service_role key in the browser belongs in an Edge Function or your own server.
The usual cause is an env var with a frontend prefix. Anything named VITE_..., NEXT_PUBLIC_... or EXPO_PUBLIC_... gets compiled into the client. If you're on the new key format, grep -rl 'sb_secret_' dist/ lists any build files that contain a secret key, without printing it.
postgres), so by default they ignore RLS on the tables underneath. On Postgres 15 and later, create them with with (security_invoker = true) so the caller's policies apply, or fix an existing one with alter view public.my_view set (security_invoker = true);. storage.objects.
While you're at it:
/.env and /.git/config shouldn't be publicly reachable. Watch out for SPA hosts that return index.html with a 200 for every path; look at the response body, not just the status code.sk_live_, sk_test_ and rk_live_ (Stripe), sk-proj- and sk-svcacct- (OpenAI), sk-ant- (Anthropic), AKIA (AWS). Older OpenAI keys start with plain sk-, but that also matches plenty of harmless strings, so check each hit by eye. Stripe pk_ keys are public by design.Access-Control-Allow-Origin: *) on your own APIs that return user data deserves a second look.
AI tools will happily write a policy that makes a permissions error go away. If a policy was added to fix an error, it's the first one to re-read. using (true) is a very effective way to make an error disappear.
Disclosure: I built VibeSheriff, which runs step 3 for you against a live site you've verified you own. It finds your Supabase URL and public key in the page and its JavaScript, picks up the table names your app's JavaScript queries, and tries to read one row from each with only that key. A table is flagged only if a row actually comes back, and only table names are reported. It's read-only. The free scan shows your score and top 3 issues. Everything above works without it, though, and I'm happy to answer RLS questions in the comments either way. 🤠