You committed a secret (an API key, a database password, a .env file) and pushed it. Here is the short version: deleting the file in a new commit does not remove it from history, so the first thing you do is rotate the leaked credential, then rewrite history with git filter-repo (or BFG) to scrub the secret from every commit, then force-push. Treat the old value as compromised the moment it touched a remote, because it was.
That ordering matters. Rewrite without rotating and you have cleaned up a key someone may already have copied. Rotate without rewriting and the dead key still sits in your history for anyone who clones the repo. Do both, in this order.
Why deleting the file in a new commit is not enough
This is the mistake almost everyone makes first. You notice the leaked config.json, you git rm it, you commit, you push, and it looks gone. It is not. Git keeps every version of every file in its history. The new commit removes the file going forward, but the old commit that added the secret is still right there, one checkout away.
# This does NOT remove the secret from history
git rm config.json
git commit -m "remove leaked config"
git pushAnyone with the repo can recover the value in seconds:
# The secret is still fully readable in the old commit
git log --oneline
git show <old-commit-hash>:config.jsonThe same applies to git revert: it adds a commit that undoes the change going forward, but the original commit, and the secret inside it, stays put. I cover that trap in more detail in git reset vs git revert. Neither command rewrites the old commit, and the old commit is the problem. To actually remove the value you have to rewrite history so the secret never existed in any commit.
Step 1: rotate the credential first (this is not optional)
Before you touch history, invalidate the leaked secret at the source. Rotating means generating a new value and revoking the old one so the exposed string stops working:
- API keys / tokens: revoke the key in the provider's dashboard (Stripe, AWS, GitHub, etc.) and issue a new one.
- Database / service passwords: change the password on the account and update wherever it is consumed.
- SSH / deploy keys: remove the public key from the server or GitHub and generate a fresh pair. My guide to adding an SSH key to GitHub covers the regeneration side.
Why first? Because the rest of this process takes time, and a public key on a public repo can be scraped within minutes by bots that watch GitHub's commit firehose. The history rewrite is cleanup; the rotation is the actual fix. A scrubbed history with a still-valid key is a false sense of security. The exposed .git directory attack walks through exactly how attackers mine credentials out of commit history that the owner believed was "removed", and it is a sobering read on why you treat any leaked secret as already compromised.
Step 2: scrub it from all history with git filter-repo
git filter-repo is the tool the Git project itself now recommends for rewriting history. The old git filter-branch carries a prominent warning in its own docs telling you to use filter-repo instead, because filter-branch is slow, error-prone, and easy to get subtly wrong. Install filter-repo first (it is a single Python script; brew install git-filter-repo, pip install git-filter-repo, or your package manager).
Remove an entire leaked file from every commit
If the secret lived in a file that should never have been committed at all (a .env, a secrets.yml, a private key), delete the file from all of history:
git filter-repo --path config/secrets.yml --invert-paths--invert-paths means "keep everything except this path", so every trace of that file is removed from every commit. To target several files at once, pass --path more than once, or use --path-glob '*.pem'.
Replace a secret string wherever it appears
If the secret was pasted inline (a key hardcoded in a source file you want to keep), replace the literal text across all history instead of deleting the file. Put the values in a file, one per line, in the form old==>new:
AKIAIOSFODNN7EXAMPLE==>***REMOVED***
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>***REMOVED***Then run:
git filter-repo --replace-text secrets-to-remove.txtEvery occurrence of those strings in every commit becomes ***REMOVED***. This is the right move when the secret is woven into a file's history but the file itself is legitimate.
After either command, verify the value is actually gone before you push:
git log --all --oneline -S 'AKIAIOSFODNN7EXAMPLE'-S searches history for commits that add or remove that string. An empty result means the secret is no longer anywhere in your local history.
git filter-repo vs BFG Repo-Cleaner
Both rewrite history; they trade off differently. BFG is faster on huge repos and has a friendlier interface for the two common cases (deleting files, replacing text), but it is a separate Java tool and deliberately will not touch your current HEAD commit (the assumption being you have already fixed the present, which you should have). filter-repo is more general and is the Git-blessed default.
| git filter-repo | BFG Repo-Cleaner | |
|---|---|---|
| Maintainer | The Git project's recommended tool | Third-party (Roberto Tyley) |
| Runtime | Python script | Java (needs a JVM) |
| Speed on large repos | Fast | Faster (purpose-built for this) |
| Scope | General history rewriting | Files / text replacement only |
| Protects current HEAD | No (rewrites everything) | Yes (refuses to alter latest commit) |
| Typical use | --path ... --invert-paths or --replace-text | --delete-files or --replace-text |
The BFG equivalents of the two examples above:
# Delete a file from all history
bfg --delete-files secrets.yml
# Replace strings listed in a file
bfg --replace-text secrets-to-remove.txtPick whichever you have installed. For most leaked-secret cleanups either is fine; reach for BFG specifically when the repo is large and filter-repo feels slow.
Step 3: force-push the rewritten history (the careful part)
Rewriting history changes every commit hash from the rewrite point onward, so your local history and the remote have now diverged. A normal git push will be rejected as a non-fast-forward, the same rejection I unpack in force-pushing safely. You have to overwrite the remote:
git push --force-with-lease --all
git push --force-with-lease --tagsI use --force-with-lease rather than a bare --force. The difference matters: --force-with-lease refuses the push if someone else has pushed to the branch since you last fetched, so you do not silently clobber a teammate's work. A plain --force overwrites unconditionally. On a shared branch, always prefer the lease. The lease check leans on your last fetch state, which is also where force-with-lease and the upstream tracking model come together: if the branch has no upstream set, the lease has nothing to compare against.
A blunt warning, because this is where people cause a second incident while cleaning up the first:
- Everyone else who has the repo must re-clone or hard-reset. Their old history still contains the secret, and if they merge or push it back, it returns. Tell your team before you force-push, not after. If a teammate panics that the old commits are "gone" from their checkout, they can usually still recover them with
git reflog, which is exactly why a force-push alone never counts as the secret being safely removed. - Open pull requests built on the old history will break and usually need to be recreated.
- The secret may already be cached upstream. On GitHub, old commits can linger in the web UI, in forks, and in cached views even after your force-push. GitHub's removing sensitive data guide tells you to open a support request to purge cached commit views, and crucially confirms why rotation came first: once it is pushed, assume it is compromised.
If this feels heavy for a mistake that took one commit, that is the point. Rewriting history on a shared remote is genuinely disruptive, which is the best argument for never committing the secret in the first place.
Stop it happening again
The cheapest fix is the one before the commit. A few habits that have saved me:
- Add secret files to
.gitignorefrom day one. A.envthat is never tracked cannot be leaked. My.gitignoreguide covers the patterns for the usual culprits. - Run a pre-commit hook that scans staged content for key-shaped strings. Git hooks can block a commit the moment a secret pattern shows up, which is far cheaper than rewriting history afterward.
- Keep tracked-but-should-be-ignored files in check. If a config file is already committed and you want it out of the index without deleting your local copy, see removing a file from Git without deleting it.
- Make review the gate, not an afterthought. If you protect the main branch with branch rules so changes land through a reviewed pull request, a second pair of eyes catches the stray
.envor hardcoded key before it ever reaches the shared history.
If you are still getting your footing with how commits and history actually work, the Git for beginners hub is the place to start, and the staging area explainer is what makes pre-commit secret scanning click.
Sources
Authoritative references this article was fact-checked against.





