You just finished a great feature. You're in the flow.
git add .
git commit -m "Fixed the bug!"
git push
Then you see it, staring back from the GitHub UI: your live API key. ๐
We've all been there. Here's the calm, correct order of operations โ from "quick save" for a mistake you caught instantly, to the deep clean for a secret that's been buried in history for months.
Step 0: Rotate the secret first (yes, before anything else)
This is the step people skip, and it's the only one that's non-negotiable. The moment a secret hits a remote, assume it's compromised. Bots scrape public GitHub for credentials within seconds.
So before you touch history:
- Revoke / rotate the leaked key, token, or password at its source (AWS, Stripe, your DB, wherever).
- Then clean the repo.
Cleaning history without rotating is like changing the locks after posting the old key online. Rotate first, always.
Case 1: You caught it before pushing (the quick save)
If the secret is only in your last, un-pushed commit, this is trivial:
# Stop tracking the file and ignore it going forward
git rm --cached .env
echo ".env" >> .gitignore
# Fold the fix into the last commit
git commit --amend --no-edit
Done. Nothing left the building. (Still rotate the key if it was ever real.)
Case 2: It's deep in history, or already pushed (the deep clean)
Once the secret is several commits back โ or on the remote โ you need to rewrite history. The modern, recommended tool is git filter-repo (Git's own docs now steer you here instead of the old filter-branch).
# Install (Python)
pip install git-filter-repo
# Option A: remove a whole file from ALL history
git filter-repo --path config/secrets.yml --invert-paths
# Option B: redact a specific string everywhere it appears
echo 'AKIAIOSFODNN7EXAMPLE==>REDACTED' > replacements.txt
git filter-repo --replace-text replacements.txt
Prefer BFG Repo-Cleaner if you like a faster, simpler tool for the common cases:
bfg --delete-files id_rsa
bfg --replace-text passwords.txt
# Expire the old refs and garbage-collect
git reflog expire --expire=now --all && git gc --prune=now --aggressive
Then: force-push and warn your team
History rewriting changes commit hashes, so you must overwrite the remote:
git push --force
โ ๏ธ This rewrites shared history. Tell every collaborator to re-clone or hard-reset โ if someone merges an old local branch, the secret comes right back. And remember: on GitHub, the old commit can still be reachable via its hash / cached views for a while, which is exactly why Step 0 (rotation) is what actually protects you.
Prevention: never live on the edge again
-
.gitignorefirst. Add.env,*.pem,secrets.*before the first commit. - Environment variables / a secret manager (Azure Key Vault, AWS Secrets Manager, Doppler) โ secrets never touch the repo.
- A pre-commit scanner so a secret is blocked before it's committed:
# gitleaks catches secrets in staged changes
brew install gitleaks
gitleaks protect --staged
Wire that into a pre-commit hook and the "cold sweat" moment mostly disappears.
TL;DR
- Rotate the secret (non-negotiable, do it first).
- Un-pushed?
git commit --amend. - In history?
git filter-repoor BFG, thengit push --force. - Tell your team to re-clone.
- Add a pre-commit scanner so it never happens again.
How do you handle secrets in your workflow โ pre-commit hooks, or living life on the edge? ๐
Written by Karam Khoury โ Lead Software Engineer (.NET & Azure) with 14+ years building secure, scalable fintech systems. More at karamkhoury.me.
Top comments (0)