Why This Matters
Any version control system exists to preserve every project change across its entire history, and, we have to admit, Git does this perfectly. So you can be sure: if a password, token, or key ever flashed through your code, it will not disappear the moment you remove it from the current code. Anyone who can read the repository can walk back to that one old commit long after everyone has forgotten it existed.
The bad news is that there is no magic pill. Rewriting history changes every commit from the leak onward, so you end up with entirely different commit hashes, and every clone, fork, and pull request that remembers the old ones becomes a liability. The good news is that the workflow is well-defined today, and the tool for it is git filter-repo.
Step Zero: Rotate the Secret
Before touching history, revoke or rotate the leaked credential. This is the only step that actually makes the leak harmless: a rewritten repository still lives on in somebody’s laptop, in a fork, or in a cached page, but a dead password opens nothing.
GitHub says this plainly in its own guide: once the secret is rotated, “going through the extra steps to rewrite the history and remove the secret may not be warranted.” Treat the rest of this post as hygiene, not as the fix.
Step One: Rewrite History Locally
The original 2015 version of this post used git filter-branch. Its own manual now opens with a warning that the command “has a plethora of pitfalls” and asks you to use git filter-repo instead. BFG Repo-Cleaner is the other well-known option, but filter-repo is the one both Git and GitHub point to, so that is what we use.
Install it with your package manager (for example brew install git-filter-repo). You need version 2.47 or newer for the --sensitive-data-removal flag.
Then work in a fresh clone. filter-repo refuses to run on a repository that does not look freshly cloned, and for a good reason: the rewrite is irreversible and ends with an immediate prune of reflogs and old objects. Do not reach for --force; clone again instead.
git clone https://example.com/you/project
cd projectPut the secrets into a file outside the repository, one per line. Each line is matched literally by default; the regex: and glob: prefixes are supported, and ==> picks a replacement other than the default ***REMOVED***:
PASSWORD1
PASSWORD2
regex:AKIA[0-9A-Z]{16}==>AWS_KEY_REDACTEDNow rewrite every branch, tag, and ref:
git filter-repo --sensitive-data-removal --replace-text ../passwords.txtIf the whole file should never have existed, drop it from history instead of editing it. Use the Git path, not just the filename, and add another --path for every location the file ever had:
git filter-repo --sensitive-data-removal --invert-paths --path config/database.yml--sensitive-data-removal fetches all refs first, so stray references outside branches and tags get cleaned too, and reports the first changed commits. Keep that output: GitHub Support will ask for it.
Verify before you push. Search the rewritten history for the strings once more, and count how many pull requests the rewrite touched:
git log --all -S 'PASSWORD1' --oneline
grep -c '^refs/pull/.*/head$' .git/filter-repo/changed-refsUntil you push, the clone is disposable. If the number of affected pull requests is scary, delete the clone and rethink.
Step Two: Overwrite the Remote
Force-push all refs at once:
git push --force --mirror originBranch protection that blocks force pushes has to be switched off for the duration. On GitHub, refs under refs/pull/ are read-only and will fail to push; everything else must succeed.
Pushing is not the end. The old commits still exist in cached views and pull request diffs on the server, in every clone your colleagues have, and in every fork. On GitHub, open a ticket with Support and give them the first changed commits and the number of affected pull requests; they can dereference the pull requests, garbage-collect the server copy, and drop cached views. They will only do this for actual secrets, and only when rotating the credential was not enough. Forks are out of your hands: you have to ask their owners to clean up or delete them.
Step Three: Clean Up Every Clone
This is the step most people skip, and it is where leaks come back. A colleague with an old clone who runs git pull and then git push after your rewrite merges the tainted history straight back in.
Tell everyone to stop pushing while you work. Afterward, the simplest instruction is “delete your clone and clone again.” Anyone with unpushed work must rebase it onto the new history, never merge; one merge commit is enough to resurrect everything you just purged. The filter-repo manual has a dedicated section on cleaning up colleagues’ clones, and it is worth sending verbatim.
Step Four: Make It Not Happen Again
- Keep secrets out of tracked files: environment variables or a secret manager, and the config filename in
.gitignore. - Run a secret scanner such as
gitleaksin a pre-commit hook, and enable push protection on the hosting side. - Stage changes deliberately.
git add .andgit commit -aare how.envends up in a commit;git diff --cachedbefore committing is how it does not.
What Changed Since 2015
The original recipe was git filter-branch --tree-filter with a Perl one-liner, followed by git reset --hard and git gc --aggressive --prune. Besides being glacially slow, that sequence never deleted the refs/original/ backup or expired the reflog, so the “removed” secret stayed reachable in the local repository. It also said nothing about rotation, force-pushing, or other clones. filter-repo does the local cleanup automatically; the rest is still your job.
Sources: git-filter-branch(1), git-gc(1), git-filter-repo manual, GitHub: Removing sensitive data from a repository.






