It happens to almost everyone, eventually.
You stage your changes, type git commit -am "wip", push — and a few minutes later realize that id_rsa (or .env, or credentials.json) was sitting in the working directory the whole time. The file is now in the remote, in every clone, in every CI cache, and worst of all: in the git history, where a simple git rm won’t touch it.
This post walks through fixing exactly that scenario with BFG Repo-Cleaner — a tool purpose-built for ripping unwanted blobs out of git history.
Why Not git filter-branch?
Git ships with git filter-branch for rewriting history. It works. It’s also notoriously slow, has a confusing CLI, and the official Git documentation now recommends against it:
git filter-branchhas a plethora of pitfalls that can produce non-obvious manglings of the intended history rewrite […] Please use an alternative history filtering tool such as git filter-repo.
BFG is a pragmatic alternative written in Scala. It does less than filter-branch, on purpose — and that’s exactly why it’s the right tool for the most common cleanup tasks: removing files, stripping sensitive strings, and shrinking large blobs.
Concretely, on a medium-sized repo (a few thousand commits), BFG runs 10–100× faster than filter-branch because it only scans blob contents once and only touches commits that actually reference the targeted blobs.
The Damage Control Mindset
Before any tool runs, internalize this: a key that was pushed to a remote is compromised.
Even if you delete it from history five minutes later, you have to assume:
- Someone (or some bot) cloned the repo in that window.
- CI/CD systems may have cached the repository.
- Mirrors, forks, and backups may retain the old objects.
So the order of operations is:
- Rotate the key first. Generate a new SSH key, deploy it, revoke the old one everywhere it was authorized (
~/.ssh/authorized_keyson every server, GitHub/GitLab deploy keys, CI variables…). - Then clean the repository history.
- Then force-push and notify collaborators.
Cleaning history without rotating is security theater.
Installing BFG
BFG is distributed as a single JAR. You need a JDK (Java 8+) installed.
# macOS via Homebrew
brew install bfg
# Or grab the JAR directly
curl -LO https://repo1.maven.org/maven2/com/madgag/bfg/1.14.0/bfg-1.14.0.jar
alias bfg='java -jar /path/to/bfg-1.14.0.jar'
Verify:
bfg --version
Step 1: Get a Fresh Mirror Clone
BFG operates on a bare mirror clone — not your working copy. This is intentional: it forces you to keep your local work separate from the rewrite, and it’s the form a remote actually stores.
git clone --mirror git@gitlab.com:you/your-repo.git
cd your-repo.git
You’ll notice there’s no working tree — just the .git internals at the top level. That’s correct.
⚠️ Backup first. Make a tarball of the mirror clone before running BFG. If something goes wrong mid-rewrite, you want the original objects to roll back to.
cd .. tar czf your-repo.git.backup.tar.gz your-repo.git cd your-repo.git
Step 2: Remove the SSH Key File from History
The simplest case: you committed a file named id_rsa and want it expunged from every commit it ever appeared in.
bfg --delete-files id_rsa
That single command:
- Scans every commit in every branch and tag.
- Rewrites any commit that contains a file named
id_rsato no longer contain it. - Leaves your latest commit alone by default — BFG refuses to rewrite the tip of a branch, on the assumption that you’ve already removed the file there.
If you forgot to delete the file from HEAD first, you’ll see:
Protected commits
-----------------
These are your protected commits, and so their contents will NOT be altered:
* commit a1b2c3d4 (protected by 'HEAD')
Fix that with a normal git rm + commit on your working clone, push, then re-run BFG on a fresh mirror.
Other useful patterns
# Remove every file named id_rsa AND id_rsa.pub
bfg --delete-files '{id_rsa,id_rsa.pub}'
# Remove anything matching a glob, anywhere in the tree
bfg --delete-files '*.pem'
# Remove a whole folder (e.g., a checked-in .ssh directory)
bfg --delete-folders .ssh --no-blob-protection
The --no-blob-protection flag is required when targeting folders, and it disables the “leave HEAD alone” safeguard — make sure HEAD is already clean.
Step 3: Replace Sensitive Strings
Sometimes the leaked content isn’t a whole file — it’s a key string baked into source code. BFG handles that too:
# patterns.txt contains one pattern per line
bfg --replace-text patterns.txt
patterns.txt example:
PRIVATE_KEY_BLOCK==>***REMOVED***
AKIA[0-9A-Z]{16}==>AWS_KEY_REMOVED
ghp_[A-Za-z0-9]{36}==>GITHUB_TOKEN_REMOVED
regex:-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----==>***SSH_KEY_REMOVED***
The ==> separator gives you the replacement; without it, matched text is replaced with ***REMOVED***. Prefix a line with regex: to switch from literal to regex matching — useful for catching multi-line PEM blocks.
This is the right approach when the leaked content was pasted into a config file you want to keep, rather than living in a dedicated key file you can wholesale delete.
Step 4: Expire and Garbage-Collect
BFG only rewrites the commit graph — it doesn’t actually delete the underlying blob objects. They linger as unreachable objects until git’s GC reclaims them.
Force the cleanup:
git reflog expire --expire=now --all
git gc --prune=now --aggressive
After this, git verify-pack -v .git/objects/pack/*.idx | sort -k 3 -n | tail should no longer list the offending blob.
Step 5: Force-Push the Rewritten History
git push --force
This is the point of no return for collaborators — every existing clone now has divergent history. Anyone with a local checkout needs to re-clone or do a careful git fetch + git reset --hard origin/<branch>. There’s no clean “rebase your work” path because every commit SHA changed.
If your remote is GitHub or GitLab, also:
- Delete or recreate any forks. Forks keep the old objects accessible via the network of related repos. On GitHub, the leaked blob is reachable through any fork SHA URL even after you force-push. Contact support if necessary.
- Invalidate CI caches. Most CI systems cache the
.gitdirectory; old objects can resurface during the next pipeline run. - Purge cached views. GitHub/GitLab cache rendered file content; old commit URLs may keep serving the leaked file for a while. Contact support to escalate if the data is sensitive.
A Realistic Walkthrough
Putting it all together, here’s the full sequence for our SSH key scenario:
# 0. Rotate the key on every system that trusted it. NOT optional.
# 1. Clean the working clone — remove the file from HEAD and push.
cd ~/dev/your-repo
git rm id_rsa
git commit -m "remove leaked SSH key"
git push
# 2. Switch to a fresh mirror clone for the rewrite.
cd /tmp
git clone --mirror git@gitlab.com:you/your-repo.git
cd your-repo.git
# 3. Backup before destructive ops.
tar czf ../your-repo.git.bak.tar.gz .
# 4. Strip the file from history.
bfg --delete-files id_rsa
# 5. Garbage-collect the unreachable blob.
git reflog expire --expire=now --all
git gc --prune=now --aggressive
# 6. Force-push the rewritten refs.
git push --force
# 7. Tell every collaborator to re-clone.
What BFG Won’t Do
A few honest limitations to know about:
- It won’t touch your latest commit by default. This is a feature, not a bug — clean HEAD first, then rewrite history.
- It can’t rewrite individual commit messages (use
git filter-repofor that). - It can’t handle case-insensitive filename matches across all platforms without surprises — be explicit.
- It doesn’t talk to your forge. Cached views, forks, and pull request blobs on GitHub/GitLab need separate attention.
- It can’t undo a leak. Any time the key existed on a public remote, you must assume it was harvested.
When to Reach for git filter-repo Instead
git filter-repo is the modern, more powerful successor to filter-branch, written in Python and now the official replacement recommended by Git itself.
Use BFG when:
- You want to delete files by name or pattern.
- You want to scrub leaked strings.
- You want a one-line command and don’t need fine-grained control.
Use git filter-repo when:
- You need to rewrite commit messages or author info.
- You want path-based filtering (keep only
subdir/, move files, etc.). - You’re splitting or merging repos.
For the “I committed a key, get it out” scenario, BFG is still the fastest and friendliest option.
Closing Thoughts
The boring truth about leaked credentials is that the cleanup is never the fix — rotation is. Treat history rewriting as hygiene after the fact, not as a substitute for revoking the credential.
That said, hygiene matters: leaving a key blob in your history is an open invitation for anyone scraping the repo to find it months later. Spend the ten minutes with BFG, force-push, and move on.
And of course — add a pre-commit scanner so you never have to read this post again.
References
- BFG Repo-Cleaner — official site
git filter-repo— modern alternative- GitHub: Removing sensitive data from a repository
- GitLab: Reduce repository size