History rewrite guide
How to permanently remove a secret from git history
You've rotated the key — good. Now comes the part most guides underexplain: actually expunging the secret from every commit that ever contained it, confirming it worked, and coordinating the aftermath. This guide covers the complete workflow with git filter-repo and BFG Repo-Cleaner, the verification steps that prove the rewrite is clean, and what to do about forks and GitHub's object cache.
- Why git rm isn't enough
- Before you start
- Choose your tool
- git filter-repo (recommended)
- BFG Repo-Cleaner
- After the force-push
- Verify it worked
- Common mistakes
- FAQ
Why git rm and committing isn't enough
Git history is an append-only directed acyclic graph. Every commit is a permanent, content-addressed snapshot of your entire project at that moment. When you run git rm .env and push a new commit, you add a commit that removes the file from the working tree — but every previous commit still contains it, intact, with the original content.
Any collaborator can run git checkout <old-sha> -- .env and retrieve the full file with the live key. Any public repository's history is browsable on GitHub at a direct SHA URL. To actually expunge the secret you must rewrite the commit graph — replace every commit that contained it with a new commit that doesn't, giving every affected commit a new SHA.
Before you start
- Rotate the key first. History rewriting is cleanup, not containment. The key must be invalid before you touch git.
- Identify exactly what to remove. Decide whether you are removing an entire file (e.g.
.env) or replacing a specific string value (e.g. a key that was hardcoded inline in a source file). The two cases use different tool flags. - Know your remote branches. Check
git branch -r— every branch that contains the secret commit needs to be rewritten and force-pushed. Feature branches are often missed. - Notify collaborators before pushing. A force-push changes every commit SHA downstream of the rewritten commit. Anyone with an existing local clone cannot simply pull — they must re-clone. Give them time to save any unpushed local work as a patch before you push.
Choose your tool
Two tools are worth using. Avoid git filter-branch — it is deprecated, orders of magnitude slower, and has known correctness issues with complex history.
git filter-repo
Recommended- Endorsed by the Git project as the
filter-branchreplacement - Single Python script, installed via
pipor Homebrew - Rewrites all refs including stash and notes
- Handles file removal AND inline string replacement
- Removes the
originremote after rewriting (intentional safety measure) - Best for: most repositories, inline secret replacement
BFG Repo-Cleaner
Alternative- Single JAR file, requires Java 8+
- Faster than filter-repo on very large repos with binary files
- Does not rewrite stash refs by default
- Protects the current HEAD commit — you must clean HEAD separately
- Operates on a bare clone
- Best for: large repositories with many binary blobs
git filter-repo (recommended)
Install
# macOS
brew install git-filter-repo
# pip (any OS with Python 3)
pip install git-filter-repo
# Verify
git filter-repo --version
Clone fresh — do not use your existing working directory
git filter-repo requires a clean repository with no stale reflogs. Running it on your existing working directory will produce warnings and may require --force, but more importantly, your local reflog references the old commits and can reintroduce them. Always clone fresh:
git clone https://github.com/yourorg/yourrepo.git repo-clean
cd repo-clean
Option A: Remove an entire file from all history
Use this when the secret lived in a dedicated file (e.g. .env, config/secrets.yml, a *.pem key file):
# Remove a single file from every commit on every branch
git filter-repo --path .env --invert-paths --force
# Remove multiple files at once
git filter-repo --path .env --path config/secrets.yml --path id_rsa --invert-paths --force
Option B: Replace an inline string value
Use this when the secret was hardcoded inside a source file that you still want to keep (e.g. const client = new Stripe("sk_live_YOURKEY")). The replacement file maps literal strings to their replacements:
# Create a replacements file (one substitution per line)
printf 'sk_live_YOUR_ACTUAL_KEY_HERE==>***REMOVED***\n' > /tmp/replacements.txt
# Run the replacement across all history
git filter-repo --replace-text /tmp/replacements.txt --force
You can include multiple lines in replacements.txt if several secrets need to be replaced simultaneously.
Add the remote back and force-push
git filter-repo removes the origin remote as a deliberate safety measure — it prevents an accidental push to the original repo before you have verified the rewrite. Add it back explicitly, then push all branches and all tags:
# Add the remote back
git remote add origin https://github.com/yourorg/yourrepo.git
# Push all rewritten branches
git push origin --force --all
# Push all rewritten tags
git push origin --force --tags
BFG Repo-Cleaner
BFG is faster than filter-repo on repositories with many large binary blobs. It requires Java 8 or later.
Download and run on a bare clone
# Download bfg.jar (see https://rtyley.github.io/bfg-repo-cleaner/)
# Requires Java 8+: java -version
# Step 1: Bare clone (BFG requires a bare clone, not a working copy)
git clone --mirror https://github.com/yourorg/yourrepo.git repo.git
cd repo.git
# Step 2a: Delete a file from all history
java -jar bfg.jar --delete-files .env .
# Step 2b: OR replace a specific secret string
# Create replacements.txt with lines like:
# sk_live_YOUR_KEY==>***REMOVED***
java -jar bfg.jar --replace-text /path/to/replacements.txt .
# Step 3: Prune the old unreachable objects
git reflog expire --expire=now --all
git gc --prune=now --aggressive
# Step 4: Force-push
git push --force
refs/stash by default. After running BFG, check your stash: git stash list. If any stash entries predate the rewrite, drop them manually: git stash drop stash@{N}. If in doubt, git stash clear drops all entries.
After the force-push
Collaborators must re-clone
A force-push rewrites every commit SHA downstream of the rewritten commit. Any collaborator who runs git pull will get a divergence error because their local SHAs no longer match the remote. The only correct action is to delete the local clone and start fresh:
# Collaborators: save any unpushed work as a patch first
git format-patch origin/main # creates .patch files you can apply later
# Then delete the local clone and re-clone fresh
cd ..
rm -rf yourrepo
git clone https://github.com/yourorg/yourrepo.git
Do not rebase local work onto the new history using old commit SHAs — that will reintroduce the rewritten commits. Apply saved patches or cherry-pick specific changes instead.
GitHub's object cache
After a force-push, the old commits become unreachable — no branch or tag points to them. But GitHub's internal object store retains unreachable objects until its garbage collector runs, which may take up to 90 days for public repositories. During this window, a direct SHA URL (e.g. github.com/yourorg/yourrepo/commit/OLD_SHA) may still resolve and show the old commit content.
For private repositories: open a GitHub Support ticket and request an immediate GC run. GitHub Support can accelerate the cleanup for sensitive data incidents on private repos.
For public repositories: the old objects may remain accessible via direct SHA URL for some time. This is why key rotation is non-negotiable — the rewrite removes the secret from normal access patterns (branches, history browsing), but the key must be invalid before that cache window closes.
Forks are independent repositories
GitHub forks maintain their own complete copy of git history. Force-pushing your repository does not update any fork. Each fork owner must re-clone from your rewritten repository to get clean history. For forks you own on private repositories, run the same filter-repo or BFG workflow independently on each fork and force-push them.
Verify the rewrite actually worked
Do not skip this step. Clone a completely fresh copy of the repository from GitHub (not from your local rewritten copy) and search for the secret across all reachable history:
# Fresh clone from the remote
git clone https://github.com/yourorg/yourrepo.git verify-clone
cd verify-clone
# Search all commit diffs across all branches for the secret value
git log --all -p | grep -n "sk_live_YOUR_SECRET"
# If nothing is returned: the secret is gone from all reachable history.
# Also search the working tree files (in case of inline replacement)
grep -r "sk_live_YOUR_SECRET" . --exclude-dir=.git
# Check stash entries
git stash list
# If any entries exist: git stash show -p stash@{N} to inspect content
If you used --replace-text, also verify the replacement placeholder appears correctly where the old key was:
git log --all -p | grep "REMOVED"
Common mistakes
- Running filter-repo on your existing working directory Your local reflog references old commits. Clone fresh every time — it takes 30 seconds and eliminates a class of errors.
-
Forgetting to push tags
Tags are refs that point to commits. If a tag points to a commit that contained the secret, the tag still exposes it. Always push with
--tagsafter rewriting. -
Forgetting feature branches
--force --allcovers all local branches that were fetched. But if you had branches that weren't fetched in your fresh clone, they won't be rewritten. Rungit fetch --allbefore the rewrite to ensure all remote branches are present. - BFG leaving the HEAD commit unchanged If the secret is still in the most recent commit when you run BFG, it will remain there. Clean the current files with a normal commit first, then run BFG to rewrite historical commits.
- Treating GitHub's cache as "fixed" immediately after pushing The force-push removes the secret from branches and browseable history, but old commit SHAs may still resolve via direct URL for weeks. The key must be rotated before the rewrite, not after.
Frequently asked questions
Does force-pushing remove a secret from GitHub's servers immediately?
No. The old commits become unreachable (no branch or tag points to them), but GitHub's internal object store retains unreachable objects until GC runs — which may take up to 90 days for public repositories. Direct SHA URLs may still resolve during this window. For private repositories, open a GitHub Support ticket to request an immediate GC run. Key rotation is the only action that guarantees the secret stops working, regardless of what GitHub's cache contains.
Do I need a fresh clone, or can I run git filter-repo on my existing working directory?
A fresh clone is strongly recommended. Your existing directory has a local reflog that references the old commits. git filter-repo will warn about this. A fresh clone gives you a clean starting state and ensures no stale local refs can reintroduce old objects. Clone fresh, rewrite, push, then verify from a separate fresh clone.
What happens to GitHub forks after I rewrite history?
Forks are independent repositories with their own complete history. Force-pushing your repo does not touch any fork. Fork owners must re-clone from your rewritten repo themselves. For forks you own, run the same rewrite workflow on each fork independently. Notify fork owners before pushing so they can save any local unpushed work.
How can I verify the rewrite actually worked?
Clone a fresh copy from GitHub (not from your local rewritten copy) and run: git log --all -p | grep -n "YOUR_SECRET_VALUE". If nothing is returned, the secret is gone from all reachable history. Also check stash with git stash list and inspect any entries that predate the rewrite.
Does git filter-repo remove the secret from git stash entries?
Yes. git filter-repo rewrites all refs including refs/stash. BFG does not process stash refs by default — after a BFG run, check git stash list and drop any stash entries that predate the cleanup with git stash drop stash@{N}.
What is BFG's "protected commits" limitation?
BFG never modifies the most recent commit on any protected branch (your current HEAD) by default. This prevents breaking live deployments but means if the secret is in your latest commit, BFG leaves it there. Fix: make a regular commit removing the secret from current files first, then run BFG. BFG rewrites all historical commits while leaving your clean HEAD intact.
What do collaborators need to do after the force-push?
They must delete their existing local clone and clone fresh from the remote. git pull will fail because every commit SHA downstream of the rewritten commit has changed. Collaborators should save any unpushed local work as patches first (git format-patch origin/main) and apply them to the new history after re-cloning. Do not rebase on old SHAs — that reintroduces old commits.
Before you push: paste the affected file or commit diff into LeakCheck to confirm you've caught every secret in the same file — it's common to find a cluster of credentials together.
More in the LeakCheck guide series
History rewriting is the remediation step after discovery. These guides cover the other moments in the secret-exposure lifecycle:
Rotate first, then scrub history. Step-by-step for AWS, Stripe, OpenAI, GitHub tokens, and more.
Detection Scan git history for secretsAudit every commit across all branches with gitleaks detect and git log -p.
Audit Find hardcoded keys in a codebaseProactive sweep of the working tree — for inherited codebases and AI-generated code.
Prevention Pre-commit hook setupBlock secrets at the staged-diff level with gitleaks and detect-secrets hooks.
Also in the Copper Bay Labs ship-safety suite
Scan your live URL for exposed .env files, .git directories, source maps, and bundled secrets.
Check your security headers and CSP so your site doesn't become an exfiltration vector after cleanup.
Dependencies DepCheckCheck your package.json for vulnerable, abandoned, and typosquatted npm packages.