Secrets hide in your Git history, even after you delete them
You committed an API key by accident. You notice, delete the line, commit again, and push. Crisis averted, right? No. That key is still sitting in your git history, one git log -p away from anyone who clones the repo. A new commit only adds a layer on top. It never removes the old one.
Why deleting the line does nothing
Git is a chain of snapshots. When you “delete” a secret in a later commit, the earlier commit that introduced it stays untouched in the object database. Anyone can check it out:
|
|
If the repo is public, assume the secret is already harvested. Bots scan every public push within seconds. The line being gone from HEAD is irrelevant.
graph LR
C1[Commit 1: add secret] --> C2[Commit 2: delete the line]
C2 --> C3["HEAD: secret looks gone"]
C1 -.->|still readable via git log / checkout| S[("Secret lives in history")]
The order of operations
There is a sequence here and skipping a step leaves you exposed.
- Rotate the credential. Revoke the leaked one at the provider. This is the only step that protects you.
- Remove it from history. Rewrite the repo so the secret never existed.
- Force-push the rewritten history. Overwrite the remote.
- Get collaborators to re-clone. Old clones still contain the secret.
- Ask the host to purge caches. On GitHub, old commits remain reachable by SHA until garbage collected.
Removing it: git filter-repo
The old advice was git filter-branch. Do not use it. It is slow and the Git project itself recommends git filter-repo instead.
Install it:
|
|
The git commands that follow are identical on macOS, Linux (zsh/bash), and Windows (PowerShell).
To strip a file that should never have been committed:
|
|
To replace a secret string everywhere it appears across all history, create a replacements.txt:
|
|
Then run:
|
|
Every occurrence of that string in every commit becomes REMOVED. The file stays, the secret is gone from the entire history.
git filter-repo rewrites every commit hash from the first affected commit onward. This is a destructive, history-changing operation. Work on a fresh clone and confirm the result before you force-push.The alternative: BFG Repo-Cleaner
If you prefer a single command for the common cases, BFG is faster and simpler, though less flexible.
|
|
BFG never touches your latest commit (it assumes HEAD is already clean), which is exactly why you must delete the secret in the working tree first.
Force-push and clean up
After rewriting, overwrite the remote:
|
|
Then expire local references and garbage collect so the loose objects go away:
|
|
On GitHub, the rewritten commit can still be reachable by its old SHA for a while, and forks keep their own copies. Open a support request to have stale references purged if the secret was sensitive.
Stop it happening again
Cleanup is the expensive path. Prevention is cheap.
- Add a pre-commit hook with gitleaks or git-secrets to block commits containing key patterns.
- Enable GitHub Push Protection and secret scanning on the repo. It rejects pushes that contain known secret formats before they ever land.
- Keep secrets in a
.envfile that is in.gitignore, and load them at runtime. Never hardcode.
|
|
seckit harden drops in the pre-commit hook and gitleaks config, and seckit scan sweeps the repo with gitleaks and trufflehog so a secret already in history surfaces before someone else finds it.