Secrets in git history: finding and erasing them
Every git repository with more than a few months of history has a secret in it. A Stripe key pasted into a config file in 2021. A test credential that was “definitely going to be removed later.” The file got deleted, the commit got rewritten — and none of it mattered. Git is append-only by design: the old blobs still sit in the object database, reachable by any clone that predates the fix, and by anyone who knows the SHA.
Deleting the file today does nothing. The secret is still in history, and history is what attackers check first. Public repositories get crawled by automated scanners within hours of a push; private ones leak through shared clones, CI caches, and forks. If a secret ever touched git, assume it is public. The first step is finding out what is actually there.
Finding secrets in history
Start with a full clone — a shallow clone won’t have the old objects. Then scan every commit, not just the current tree:
- gitleaks scans commit history against known secret patterns (API keys, tokens, private keys) and reports the commit, file, and rule that matched.
- trufflehog searches for high-entropy strings across the entire history.
- A plain
git log -p --allpiped throughgrepworks in a pinch for known key formats, but pattern tools catch formats you didn’t think to search for.
For a quick inventory, git rev-list --all lists every commit; check each
tree for suspicious filenames (.env, *_key, credentials*).
Automated tools are faster and more thorough — run them on a schedule.
The three rewriting tools
Once you know what leaked, you have to rewrite history so the blobs cease to exist. Three tools do this, and they are not equivalent.
git filter-branch is the built-in option:
git filter-branch --tree-filter 'rm -f .env' -- --all. It works everywhere,
it is slow (it checks out every commit), and it has sharp edges — the git
project itself discourages it in favor of filter-repo.
BFG Repo-Cleaner is faster and simpler:
bfg --delete-files .env, then git reflog expire --expire=now --all and
git gc --prune=now --aggressive. It handles the common cases well, but it
is a Java jar with a narrower feature set.
git filter-repo is the current recommendation. One command handles file removal, path rewriting, and history analysis, and it is dramatically faster:
git filter-repo --invert-paths --path .env --path config/secrets.json
filter-repo removes refs and garbage-collects aggressively so the old objects are actually unreachable, not merely hidden. The trade-off: every commit SHA changes, so collaborators, forks, and open PRs must be re-cloned or rebased onto the new history.
After the rewrite
Rewriting history is necessary but not sufficient:
- Rotate the secret. Copies of the old value live in logs, CI caches, and the browser history of anyone who ever viewed the file. Rewriting the repo does not un-leak a key; only rotation does.
- Force-push and coordinate. Every stale clone is a copy of the secret. Have collaborators re-clone and invalidate CI caches.
- Clean local reflogs.
git reflog expire --expire=now --allplus a prune makes the old objects unreachable locally; filter-repo does this for you.
The deeper lesson is that erasure is retroactive, expensive, and never
complete — which is why the industry keeps moving toward secrets that never
enter the repo. Tooling like trustless,
a single-binary credential broker CLI for AI agents, keeps keys in your
existing pass or Bitwarden store and injects them into subprocess
environments at runtime, so there is nothing to commit. And when a
secret still ends up in a file or a session database, its dlp scrub-text
and scrub-db commands apply the same redaction rules used by its outbound
DLP — dry-run by default, with FTS rebuild and VACUUM for database files.
When was the last time you scanned your repositories’ history — and what did the scan turn up?