DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

You Committed .env to GitHub: What to Rotate (Supabase + Next.js)

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

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. Any NEXT_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_KEY
  • SUPABASE_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
Enter fullscreen mode Exit fullscreen mode

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 leaked sb_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:

  1. Copy the new service_role / sb_secret_ key into your server environment (Vercel project env vars, .env.local, etc.).
  2. 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
Enter fullscreen mode Exit fullscreen mode

Or with BFG:

bfg --delete-files .env
git reflog expire --expire=now --all && git gc --prune=now --aggressive
Enter fullscreen mode Exit fullscreen mode

Then force-push the rewritten history:

git push origin --force --all
git push origin --force --tags
Enter fullscreen mode Exit fullscreen mode

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

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 TO clause applies to every role, including anon.
  • USING (true) on a SELECT policy makes the table world-readable. It's only world-writable if a permissive INSERT/UPDATE/ALL policy also exists (INSERT policies gate rows with WITH CHECK, not USING).
  • For an anonymous request, auth.uid() is NULL — so USING (auth.uid() = user_id) correctly returns nothing to anon.

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

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

  1. Don't panic, don't just delete the file.
  2. Anon key + project URL + NEXT_PUBLIC_ vars: safe, no rotation needed.
  3. service_role, JWT secret, DB password, third-party keys: rotate now, revoke the old ones.
  4. Purge history with filter-repo/BFG and force-push — but only after rotating.
  5. 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)