If you built something on Supabase with a lot of AI help and you're about to ship it, this checklist is for you. Supabase is secure by default in the sense that the tools are all there — but "vibe coding" tends to skip the boring parts, and the boring parts are exactly where data leaks live. Every item below is a one-line check plus the fix. Run them in order. It's maybe 30 minutes, and it's the difference between a launch and an incident.
1. RLS is enabled on every table in the public schema
The single most common Supabase mistake: a table exists, the anon key can reach it over PostgREST, and Row Level Security was never turned on. With RLS off, the table's GRANTs are the only gate — and because Supabase exposes the public schema to the anon role by default, that means anyone holding your (public) anon key can read it.
Check — run this in the SQL editor to list every public table with RLS off:
select tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
Any rows returned are exposed. Fix each one:
alter table public.your_table enable row level security;
Enabling RLS with no policies is deny-all — a safe default. Now add the policy you actually want.
2. Every table has owner-scoped policies (not using (true))
Enabling RLS isn't enough if your policy waves everyone through. The classic leak is using (true) with no to clause. A SELECT policy like that is world-readable, anon included. Two facts to keep straight: a policy with no TO clause applies to all roles, and for anon, auth.uid() is NULL — so any check that compares against it silently fails open or closed depending on how you wrote it.
Check — list every policy and read it:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public';
qual is the USING expression; with_check is the WITH CHECK expression. A bare true in qual on a SELECT policy is world-readable. (It's only world-writable if a permissive INSERT/UPDATE/ALL policy also allows it — reads and writes are separate policies.) Fix with owner scoping:
-- users can only read their own rows
create policy "own rows are selectable"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );
-- and only insert rows they own
create policy "insert own rows"
on public.todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );
Two things people get wrong here:
-
USINGvsWITH CHECK.USINGfilters which existing rows a role can see, and which it may update or delete.WITH CHECKvalidates rows being written. AnINSERTpolicy hasWITH CHECKonly — there are no existing rows to filter.UPDATEuses both. If you protect reads but forgetWITH CHECKon writes, users can insert or update rows they shouldn't own. - A policy and a grant. A role needs a matching policy and the table GRANT. Multiple permissive policies OR together; restrictive policies AND. If access feels "too open," look for a second permissive policy widening things.
3. You're using the right key in the right place
Every Supabase project has two keys, and confusing them is catastrophic.
-
anon / publishable (
role: anon, new formatsb_publishable_...) is public by design. It's meant to ship in your browser bundle. It is not a secret, and it does not need rotating. RLS is what protects your data when this key is used. -
service_role (
role: service_role, new formatsb_secret_...) is a server-only secret that bypasses RLS entirely — full read/write/delete across every table, plus Storage. It must never appear in a browser, a public repo, or a client config file.
Not sure which is which? Decode the JWT payload — the middle segment, base64URL — and read the role:
# paste your key; this prints the payload
echo 'PASTE_KEY' | cut -d. -f2 | base64 -d 2>/dev/null; echo
"role":"anon" is the browser key. "role":"service_role" must never leave your server.
The trap in frontend frameworks: any env var prefixed NEXT_PUBLIC_, VITE_, or EXPO_PUBLIC_ is inlined into the client bundle at build time. That's correct and safe for the anon key and project URL. Putting the service_role key behind one of those prefixes ships your master key to every visitor. Check your env files:
grep -rE 'NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_' .env* | grep -i 'service\|secret'
Anything returned is a bug. The service_role key belongs only in server-side env — API routes, edge functions, backend — never behind a public prefix.
4. No secrets in the repo — or in git history
Deleting a secret file doesn't help if you already committed it once. A committed secret lives in git history forever. AI tooling makes this worse by scattering config files people don't think to check.
Check the working tree, including the easy-to-miss ones:
grep -rnE 'service_role|sb_secret_|eyJ[A-Za-z0-9_-]{20,}' \
. --include='*.json' --include='*.env*' --include='*.ts' 2>/dev/null
# the ones vibe-coders forget:
git ls-files | grep -E '\.env|\.claude/settings\.local\.json|\.cursor/mcp\.json'
.claude/settings.local.json, .cursor/mcp.json, and .env.local love to hold live keys and DB connection strings. Then check history:
git log --all --full-history -p -- '.env*' '**/mcp.json' '.claude/settings.local.json'
Fix, in this order:
-
Rotate first. If a
service_rolekey (or DB password) was ever committed, treat it as compromised. Regenerate it in the dashboard under Project Settings -> API. Deleting the file does not un-leak it. -
Purge history with
git filter-repo(or BFG), then force-push:
git filter-repo --path .env.local --invert-paths
- Add the patterns to
.gitignoreso it can't recur:
.env*
.claude/settings.local.json
.cursor/mcp.json
Order matters: rotate before you spend time rewriting history, because the old key is public the moment the repo is.
5. Storage buckets have real policies
Storage is Postgres too — access lives in storage.objects and obeys RLS. A public bucket means the files are readable by URL to anyone. A private bucket with no policy means no one can read it, which is usually not what you shipped either.
Check your buckets and their object policies:
select id, name, public from storage.buckets;
select policyname, cmd, roles, qual
from pg_policies
where schemaname = 'storage' and tablename = 'objects';
Decide per bucket: are these files meant to be world-readable (avatars, marketing images) or private (invoices, uploads)? For an owner-scoped private bucket, a common pattern keys access to a per-user folder:
create policy "users read own folder"
on storage.objects for select
to authenticated
using (
bucket_id = 'user-uploads'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
6. Do a real self-audit before you trust the checklist
Reading policies by hand is exactly where over-confidence creeps in — a policy can look scoped and still leak because of a second permissive policy or a missing TO. So verify empirically.
I keep a small free repo for this: github.com/cekuu35/supabase-rls-leak-demo. It's a minimal reproduction of the classic RLS leak plus read-only audit SQL you can paste into your own SQL editor to list tables without RLS, policies with no TO clause, and using (true) permissive policies in one shot. It changes nothing in your database — it just tells you where you stand.
The most honest test, though, is to hit your project the way an attacker would: with nothing but the public anon key.
curl "https://<project-ref>.supabase.co/rest/v1/your_table?select=*" \
-H "apikey: <ANON_KEY>"
If that returns rows you expected to be private, RLS is not doing what you think. Fix it before launch, not after.
Run all six and you've closed the failure modes behind almost every "Supabase leaked my data" post-mortem: RLS off, using (true), the wrong key in the bundle, and a secret buried in git history.
If you'd rather not assemble this by hand, I packaged the whole thing — the audit queries, owner-scoped policy templates for the common table shapes, the Storage patterns, and a printable pre-launch checklist — into a $29 RLS Audit Kit. The free demo repo above is genuinely enough to secure your project; the kit just saves you the afternoon of writing the SQL yourself. Either way, ship it locked down.
Top comments (0)