If you've been vibe-coding a Supabase app with Claude Code, Cursor, aider, or an MCP setup, there's a leak vector almost nobody is talking about yet: your AI tool's own config files. They quietly capture your service_role key and commit it to your repo, where bots scrape it within minutes of a push.
Here's exactly how it happens, how to check your whole repo (including history), and how to actually fix it.
First: you have two keys, and only one is a secret
Supabase hands every project two keys, and they are not equal.
- The anon / publishable key is public by design. It ships in your browser bundle. It is not a secret, and you do not rotate it. Row Level Security (RLS) is what protects your data when someone hits your API with this key.
- The service_role key is a server-only secret that bypasses RLS entirely — full read/write/delete on every table, plus Storage. It must never reach a browser, a public repo, or any client-side config.
There are two key formats in the wild, so know both:
-
Legacy JWT keys — long tokens starting with
eyJ. Decode the middle segment to read the role. JWT payloads are base64*url* and unpadded, so a naivebase64 -doften errors and prints nothing — which can look like a false all-clear. Normalize and pad first:
KEY="paste-your-key-here"
p=$(echo "$KEY" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
echo "$p" | base64 -d 2>/dev/null | grep -o '"role":"[a-z_]*"'
# macOS: if base64 -d fails, use base64 -D
"role":"anon" is fine anywhere. "role":"service_role" committed anywhere public is a critical incident.
-
New keys (Supabase's 2025 format) are not JWTs — the prefix tells you everything:
sb_publishable_...is public,sb_secret_...is the secret. No decoding needed. A committedsb_secret_is the emergency.
How the AI tool leaks it
The pattern is always the same: your tool needs the key to do admin work, writes it into a config file, and that file gets committed because nobody added it to .gitignore.
-
Claude Code —
.claude/settings.local.json. Approve an allow-listed bash command once and the literal command is saved. If you ever ran acurlwith anapikey: <service_role>header, that full key now lives in the allow list. -
Cursor / MCP —
.cursor/mcp.jsonor.mcp.json. MCP servers take anenvblock, and the Supabase MCP server wantsSUPABASE_SERVICE_ROLE_KEYright there in plaintext JSON. - Same story for
.continue, aider config, and.vscode/settings.json.
Why is this catastrophic? Because service_role skips RLS. Whoever finds the key doesn't need to break your policies — the key is the break. They read every user's rows, edit balances, delete tables, and empty your Storage buckets.
Where service_role keys leak: the full checklist
Beyond AI config, these are the usual suspects. Run each from your repo root.
1. AI coding-tool config
git grep -nE 'service_role|SUPABASE_SERVICE|sb_secret_' -- \
'.claude/*' '.cursor/*' '.mcp.json' '.continue/*' '.aider*' '.vscode/settings.json'
2. Committed .env variants — .env is usually ignored, its siblings often aren't (.env.local, .env.production, .env.staging, .env.test):
git ls-files | grep -E '(^|/)\.env'
3. The trailing-space .env trick — a .gitignore rule for .env does not match a file literally named .env (trailing space). It slips right through:
git ls-files | grep -P '\.env ' # note the space
4. The NEXT_PUBLIC_ / VITE_ / EXPO_PUBLIC_ trap — these prefixes tell your bundler to inline the value into the client bundle. NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY ships your secret to every visitor:
git grep -nE '(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_).*(SERVICE_ROLE|SERVICE_KEY)'
5. Deploy config — vercel.json env blocks, netlify.toml, wrangler.toml/.dev.vars, and railway/render/fly config:
git grep -nE 'service_role|SERVICE_ROLE|sb_secret_' -- \
vercel.json netlify.toml wrangler.toml '.dev.vars' 'render.yaml' 'fly.toml'
6. CI and test config — GitHub Actions with inline secrets, cypress.env.json, docker-compose:
git grep -nE 'service_role|SERVICE_ROLE|sb_secret_' -- \
'.github/workflows/*' 'docker-compose*.yml' Dockerfile
git ls-files | grep -E 'cypress.env.json|\.env.test'
7. Seed / migrate / admin scripts and committed SQL/JSON dumps — these legitimately need service_role, then get committed with the literal inlined:
git grep -nE 'service_role|sb_secret_' -- 'scripts/*' 'seed*' 'supabase/seed*' '*.sql' '*.json'
One important gotcha: a leaked legacy key is an eyJ... JWT whose payload does not contain the plaintext string service_role. If a key was inlined bare — createClient(url, 'eyJ...') — or hidden behind a generic name like SUPABASE_KEY=, every grep service_role above misses it. So also sweep for the token itself:
git grep -nE 'eyJhbGci|sb_secret_'
Check your whole repo, including history
Deleting a file today does nothing — a committed secret lives in git history forever and is recoverable from any past commit. Search history, not just the working tree. The -G flag matches the token by regex (unlike -S, which only fires when a string's count changes):
git log -p --all -G 'eyJhbGci|sb_secret_' | grep -nE 'eyJhbGci|sb_secret_'
On a large or long-lived repo, run a dedicated scanner instead — gitleaks or trufflehog also catch dangling commits the command above can miss:
gitleaks detect --source . --redact
Prefer not to eyeball this? A free repro plus read-only audit SQL that flags committed-key patterns, RLS-off tables, and anon-open policies lives here: github.com/cekuu35/supabase-rls-leak-demo. Clone it, point it at your project, done.
Found one? Rotate first, then purge history
Order matters. Rotation is what actually kills the attacker's access; purging keeps the old value out of clones and stops the next leak.
1. Rotate the key. Supabase Dashboard → Settings → API.
-
New keys: revoke and reissue the
sb_secret_key independently — the publishable key and user sessions are untouched. Takes 30 seconds. - Legacy keys: there's no isolated "roll service_role" button. Rotating regenerates the shared JWT secret, which also invalidates the anon key and logs out every user. Plan for it, then redeploy clients with the new anon key.
Copy the new value into your server environment before touching history.
2. Purge it from history. Using git-filter-repo (the tool Git now recommends). Put the leaked string in a file so it's scrubbed from every commit:
# expressions.txt
literal:eyJhbGciOi...your-leaked-key...==>REMOVED
pip install git-filter-repo
git filter-repo --replace-text expressions.txt
git remote add origin <your-repo-url> # filter-repo drops the remote by default
git push --force --all
git push --force --tags
Prefer BFG? Note the format differs — no literal: prefix, and remove the file from HEAD first (BFG won't touch the tip commit):
# bfg-replacements.txt → one line: eyJhbGciOi...your-leaked-key...==>REMOVED
git rm --cached path/to/leaked-file && git commit -m "remove secret"
bfg --replace-text bfg-replacements.txt
git reflog expire --expire=now --all && git gc --prune=now --aggressive
git push --force
Forks and open PRs keep their own copies, so rotation in step 1 is what truly protects you.
Lock it down so it can't recur
Fix .gitignore carefully — a bare .env* also swallows .env.example, which you usually want committed:
.env
.env.*
!.env.example
.claude/settings.local.json
.cursor/mcp.json
.mcp.json
.dev.vars
cypress.env.json
And keep the service_role key strictly in server code (route handlers, server actions, edge functions) with a bare SUPABASE_SERVICE_ROLE_KEY name — never a public framework prefix.
Don't forget RLS itself (defence in depth)
Even with the secret safe, your anon key is public — so any table with RLS disabled or a permissive USING (true) policy is exposed to the whole internet. A few facts people mix up:
-
USINGfilters which existing rows a role can see (and scans for UPDATE/DELETE);WITH CHECKvalidates rows being inserted or updated. - A
USING (true)SELECT policy makes a table world-readable; it only becomes world-writable if a permissiveINSERT/UPDATE(orFOR ALL) policy also exists. - A role needs both a matching policy and the table
GRANTto touch data.
Turn RLS on for every table in public and scope policies to auth.uid().
That's the whole loop: know your two keys, sweep working tree and history for eyJ/sb_secret_, rotate then purge, lock down RLS. If you want to go deeper, I keep a $29 RLS Audit Kit — a longer checklist covering policy edge cases, Storage rules, and the leak spots above — but the free demo repo and the commands here will catch the vast majority of real leaks.
Top comments (0)