Deleting a file or removing a secret from your latest commit does not remove it from git history. Anyone with access to your repo can still find it by browsing old commits. This guide walks through how to actually remove secrets from your git history permanently.
First, Rotate the Secret Immediately
Before doing anything with git, go to the source and revoke the exposed credential. The AWS IAM console, the Stripe dashboard, the OpenAI API keys page, wherever the key was issued. Do this first, before you touch history at all.
Treat the credential as already compromised, regardless of how long it has been sitting in the repo or whether the repo is public or private. Automated scanners monitor commits continuously, and a private repo can still leak through a fork, a CI log, or a former collaborator's local clone.
Cleaning git history does not undo any damage that may already have occurred. It stops the secret from being visible going forward. It does not un-leak it.
Why git rm and git commit Do Not Work
Git history is append-only by design. Every commit is a snapshot, and once you push a commit, that snapshot exists permanently in the repo's history unless you deliberately rewrite it. Running git rm secrets.env and committing the change only removes the file from the current snapshot going forward. It does nothing to the commit where you first added it.
That old commit is still reachable. git log -p will show it, git show <commit>will print it, and GitHub's commit browser will happily display the diff where the secret was introduced. If someone has the commit SHA or just scrolls back through history, they see the original value. Deleting the file from HEAD is not deleting the secret. You need to rewrite history, not just update the latest state.
Option 1: git filter-repo (Recommended)
git filter-repo is the officially recommended tool for this, and it replaces the older git filter-branch, which is slower and easier to use incorrectly. Install it with:
pip install git-filter-repoTo remove a specific file from every commit in history:
git filter-repo --path secrets.env --invert-pathsTo replace a specific string across all of history, instead of removing an entire file, create a text file with the pattern to replace:
# expressions.txt
EXPOSED_KEY==>REDACTEDThen run:
git filter-repo --replace-text expressions.txtAfter the rewrite completes, force push the result:
git push origin --force --allThis rewrites every commit SHA after the point where the secret was introduced. That is expected, but it affects everyone who has a local clone. Anyone who pulls normally after this will hit a diverged history error. They need to re-clone or reset to the new history, not merge it in.
Option 2: BFG Repo Cleaner
BFG Repo Cleaner is faster than git filter-repo on large repos and has simpler syntax for the common cases. It requires Java to run. To delete a specific file from history:
bfg --delete-files secrets.envTo replace a list of known secret values across all commits, put the values in a text file and run:
bfg --replace-text passwords.txtBFG does not rewrite history in place immediately. After it finishes, you still need to clean up the old objects and finalize the rewrite:
git reflog expire --expire=now --all
git gc --prune=now --aggressiveAfter Rewriting History
Rewriting history locally does nothing on its own. You need to push the result and make sure everyone else is working off the new history, not the old one:
- Force push all branches:
git push origin --force --all - Force push all tags:
git push origin --force --tags - Notify every collaborator to re-clone the repo. Their local copies still have the old history with the secret in it, and a normal
git pullwill not fix that. - If the repo is public, contact GitHub support to clear cached views and any forks or pull request references that might still point at the old commits.
- Rotate the secret again if there was any delay between discovery and cleanup. A key that sat exposed for even a few hours should be treated as burned.
How to Stop This Happening Again
Getting the history clean is the reactive part. The real fix is not needing to do this again:
- Add sensitive files to
.gitignorebefore writing any secrets to them, not after the first commit. - Use pre-commit hooks to block credential patterns from being committed in the first place, so the secret never makes it into a commit at all.
- Run regular scans on your repo and commit history. Scan your GitHub repo for secrets covers the tools and approach in detail.
- Store secrets in proper secret managers, not in code or config files that live in the repo.
For the specific patterns that give away exposed keys, see GitHub secret scanning. For the rest of your pre-launch security surface, the GitHub repo security checklist covers what else to check before you ship. And if you want to confirm your history is actually clean after a rewrite, scan your repo for exposed secrets and verify nothing was missed.
Rewriting git history is a last resort, but sometimes it is the only option. The real fix is catching secrets before they ever get committed. Once your history is clean, put the prevention steps in place so you are not doing this again in six months.