DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

The Supabase Security Checklist to Run Before You Launch

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;
Enter fullscreen mode Exit fullscreen mode

Any rows returned are exposed. Fix each one:

alter table public.your_table enable row level security;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

Two things people get wrong here:

  • USING vs WITH CHECK. USING filters which existing rows a role can see, and which it may update or delete. WITH CHECK validates rows being written. An INSERT policy has WITH CHECK only — there are no existing rows to filter. UPDATE uses both. If you protect reads but forget WITH CHECK on 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 format sb_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 format sb_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
Enter fullscreen mode Exit fullscreen mode

"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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

.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'
Enter fullscreen mode Exit fullscreen mode

Fix, in this order:

  1. Rotate first. If a service_role key (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.
  2. Purge history with git filter-repo (or BFG), then force-push:
git filter-repo --path .env.local --invert-paths
Enter fullscreen mode Exit fullscreen mode
  1. Add the patterns to .gitignore so it can't recur:
.env*
.claude/settings.local.json
.cursor/mcp.json
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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)