You pushed, then your stomach dropped: .env is in the commit. Maybe a bot already emailed you. Take a breath — this is recoverable, and panicking into random git commands usually makes it worse.
The single most important thing to understand: deleting the file does not fix anything. Once a secret is in git history, assume it is public forever. The real fix is rotation — making the leaked value useless by replacing it upstream. Purging history is cleanup you do after rotating, not instead of it.
Let's go value by value through a typical Supabase + Next.js .env, sort out what's actually a secret, and rotate only what matters.
First: which of these is actually secret?
A typical .env looks like this:
NEXT_PUBLIC_SUPABASE_URL=https://abcdxyz.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi... # role: anon
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi... # role: service_role
SUPABASE_JWT_SECRET=super-secret-signing-key
DATABASE_URL=postgresql://postgres:PASSWORD@db.abcdxyz.supabase.co:5432/postgres
STRIPE_SECRET_KEY=sk_live_...
RESEND_API_KEY=re_...
Here's the part that saves you hours of unnecessary panic.
Safe by design — do NOT need rotating:
-
NEXT_PUBLIC_SUPABASE_URL— your project URL is public. It's in every network request the browser makes. -
NEXT_PUBLIC_SUPABASE_ANON_KEY— the anon (publishable) key is public by design. It ships in your client bundle on purpose. AnyNEXT_PUBLIC_variable is inlined into the browser JavaScript at build time, so it was never a secret to begin with.
The anon key's role is anon. It has no special power on its own — it's just an identity meaning "an unauthenticated (or logged-in) user of this project." What protects your data when that key is used is Row Level Security (RLS). If your tables have RLS enabled with correct policies, a leaked anon key is a non-event. If they don't, that key was already exposed to every visitor of your site — the git commit changed nothing.
Actually secret — rotate every one of these:
SUPABASE_SERVICE_ROLE_KEYSUPABASE_JWT_SECRET-
DATABASE_URL(the Postgres password inside it) - Every third-party key: Stripe, Resend, OpenAI, etc.
The service_role key is the scary one. Its role is service_role, and it bypasses RLS entirely — full read, write, and delete on every table, plus Storage. It's a server-only secret. It must never appear in a browser, a public repo, or anything with a NEXT_PUBLIC_ prefix. If it leaked, treat it as a full database breach until rotated.
How to tell the two keys apart
Both are JWTs, so they look identical at a glance. Decode the middle segment (base64URL) and read the role claim:
# paste your key in place of PASTE_KEY
echo 'PASTE_KEY' | cut -d. -f2 | base64 -d 2>/dev/null | python -m json.tool
You'll see either "role": "anon" (safe, public) or "role": "service_role" (secret, rotate now). Newer Supabase projects use clearer prefixes instead of JWTs: sb_publishable_... (public) vs sb_secret_... (secret). The prefix tells you everything — no decoding needed.
Rotating each secret
The Supabase keys
In the dashboard, go to Project Settings -> API Keys. How rotation works depends on which key system your project uses:
-
New keys (
sb_publishable_/sb_secret_): these are independent of each other and of the JWT secret. Revoke the leakedsb_secret_key and create a new one; the publishable key is unaffected because it wasn't a secret. - Legacy JWT keys: both the anon and service_role keys are JWTs signed by your project's JWT secret. Rolling that secret regenerates both keys at once, because both are signatures over it.
Either way, after rotating the secret:
- Copy the new service_role /
sb_secret_key into your server environment (Vercel project env vars,.env.local, etc.). - If you rolled a legacy JWT secret, also grab the regenerated anon key and redeploy so the fresh key ships in your bundle.
One consequence to expect on the legacy path: rolling the JWT secret invalidates every JWT signed with the old one, which logs out all existing user sessions. That's the correct, safe outcome after a leak — do it anyway.
Database password
Project Settings -> Database -> Reset database password. This changes the password embedded in your DATABASE_URL / connection string. Update that string everywhere it lives (Vercel, CI secrets, local .env.local, migration tooling). Anything still using the old password starts failing — that's your checklist of places to fix.
Third-party keys
Each provider has its own flow, but the pattern is identical: generate a new key, deploy it, then revoke the old one. Don't skip the revoke — a new key does not disable the leaked one.
- Stripe: Developers -> API keys -> roll the leaked secret key.
- Resend / OpenAI / etc.: create new key, swap in env, delete old.
Now purge git history
Rotation makes the leaked values worthless. Purging history is about hygiene and not re-leaking on the next clone. Deleting the file in a new commit is not enough — the old commit still contains it.
The clean tool is git filter-repo:
pip install git-filter-repo
# remove the file from all of history
git filter-repo --path .env --invert-paths
Or with BFG:
bfg --delete-files .env
git reflog expire --expire=now --all && git gc --prune=now --aggressive
Then force-push the rewritten history:
git push origin --force --all
git push origin --force --tags
Two honest caveats: force-pushing rewrites history for every collaborator (they'll need to re-clone or reset), and GitHub caches commits — forks and cached commit views can retain the blob even after your push. That's exactly why rotation is the real fix and history-purging is secondary. Then add .env to .gitignore so this can't recur:
echo ".env" >> .gitignore
echo ".env.local" >> .gitignore
git rm --cached .env # stop tracking without deleting your local copy
The part everyone skips: is your RLS actually protecting you?
Here's the uncomfortable truth. If a leaked anon key genuinely exposes your data, the anon key wasn't the vulnerability — your RLS was. That key is public to every visitor regardless of any git leak.
A few things worth checking on every table:
- RLS is enabled on the table. Enabling it with no policies denies all access through the API — that's fail-safe, not broken.
- A role needs both a matching policy and the underlying table
GRANT. - A policy with no
TOclause applies to every role, includinganon. -
USING (true)on aSELECTpolicy makes the table world-readable. It's only world-writable if a permissiveINSERT/UPDATE/ALLpolicy also exists (INSERTpolicies gate rows withWITH CHECK, notUSING). - For an anonymous request,
auth.uid()isNULL— soUSING (auth.uid() = user_id)correctly returns nothing toanon.
Quick audit of what's exposed:
-- tables with RLS OFF (guarded only by GRANTs, no policy gate)
select relname
from pg_class
where relkind = 'r'
and relnamespace = 'public'::regnamespace
and not relrowsecurity;
-- every policy, so you can eyeball no-TO and USING(true)
select schemaname, tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
If reading those results makes you nervous, I put together a free, read-only repro and audit kit here: github.com/cekuu35/supabase-rls-leak-demo. It has a deliberately leaky schema plus copy-paste SQL that flags world-readable tables and no-TO policies, so you can see exactly what an anon key can reach in your own project.
The 60-second recap
- Don't panic, don't just delete the file.
-
Anon key + project URL +
NEXT_PUBLIC_vars: safe, no rotation needed. - service_role, JWT secret, DB password, third-party keys: rotate now, revoke the old ones.
-
Purge history with
filter-repo/BFG and force-push — but only after rotating. - Audit your RLS, because that's what actually stands between the public anon key and your data.
If you want to go deeper than the free audit — a full policy-by-policy checklist, the common false-positive traps, and fix templates — I keep a $29 RLS Audit Kit that packages it all up. Totally optional; the free demo repo above is enough to check and fix most projects on your own. Either way: rotate first, breathe second, and get RLS right so the next leak is a shrug instead of a scramble.
Top comments (0)