Secrets in Git: Detection, Rotation, and Rewriting History
How to catch API keys and passwords before they hit a commit, what to do the moment one leaks, and why rewriting git history never replaces rotating the credential.
Every team that has used git long enough has a story: a .env file committed by mistake, a hardcoded database password in a “temporary” debug script that shipped anyway, an AWS access key pasted into a commit message while troubleshooting at 2 a.m. Git’s design makes this worse than it sounds, because a commit is never really deleted — it stays reachable from reflogs, forks, CI caches, and anyone who cloned the repo before the mistake was noticed. A secret committed once should be treated as compromised forever, regardless of how quickly it gets removed from the tip of the branch.
The industry’s response to this has matured into a fairly standard playbook: catch secrets before they’re committed with pre-commit hooks, catch what slips through with CI scanning and host-side push protection, and — when a secret does leak — follow a strict order of operations where rotation always comes first and rewriting history is a distant, optional cleanup step that never substitutes for it. Teams that get the order backwards (spending hours on git filter-repo before rotating the credential) leave the actual vulnerability open the entire time they’re “fixing” it.
This is a rare corner of security where the tooling is genuinely good and mostly free. There is very little excuse left, in 2026, for a team of any size not to have at least the detection layer in place.
Prevention: Pre-Commit Scanning
The cheapest place to catch a secret is before it ever leaves a developer’s machine. gitleaks and trufflehog are the two dominant open-source scanners; both work by pattern-matching against known credential formats (AWS keys, GitHub tokens, private key headers, JWTs) and entropy analysis for generic high-randomness strings that look like secrets even without a known format.
# Install gitleaks and pre-commit, then wire the hook in
brew install gitleaks # or: go install github.com/gitleaks/gitleaks/v8@latest
pip install pre-commit
pre-commit install
# Now every `git commit` runs gitleaks against the staged diff first
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
A gitleaks config lets you tune false positives without disabling detection wholesale — test fixtures with obviously fake keys are the most common source of noise:
# .gitleaks.toml
[allowlist]
description = "Known test fixtures"
paths = [
'''tests/fixtures/.*''',
]
regexes = [
'''AKIAFAKEEXAMPLE1234''',
]
trufflehog takes a complementary approach worth running in CI even if developers use gitleaks locally: it verifies credentials live against the actual provider API (an AWS key gets a real sts:GetCallerIdentity check) rather than just pattern-matching, which sharply cuts false positives on a full historical scan.
# Scan the full history of a repo, not just the working tree
trufflehog git file:///path/to/repo --since-commit HEAD~500 --results=verified
Catching What Slips Through: CI and Push Protection
Pre-commit hooks are opt-in and bypassable (git commit --no-verify), so they are a courtesy, not a control. The actual control lives in CI and at the git host.
# .github/workflows/secrets-scan.yml
name: secrets-scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history, not a shallow clone
- uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: false
GitHub’s own push protection goes a step further than post-push scanning: it inspects the payload before accepting the push and rejects it outright if a recognized secret pattern is found, which stops the leak from ever reaching the remote in the first place (barring an explicit developer override with a documented reason, which is itself logged and auditable). If your org is on GitHub Enterprise or GitHub.com with Advanced Security, enabling push protection organization-wide is a single settings change that closes the most common leak path — an accidental git add . — with essentially no false-positive cost to legitimate work.
# Confirm push protection status via the GitHub API
gh api orgs/furybee/code-security-and-analysis \
--jq '.secret_scanning_push_protection.status'
Self-hosted GitLab has an equivalent (secret_detection in GitLab CI’s built-in SAST templates), and Bitbucket offers a comparable pre-receive hook model — the mechanism differs by host, but the principle is universal: reject at the push, don’t just report after the fact.
The Moment a Secret Leaks: Order of Operations
This is the part teams most often get wrong under pressure. When a secret is discovered in a commit — whether by a scanner, a security researcher, or an uncomfortable Slack message from GitHub’s own leak-detection partnership — the sequence is not negotiable:
- Rotate the credential immediately. Generate a new key/password/token at the provider, and revoke the old one. This is the only step that actually stops an attacker who already has the leaked value.
- Confirm rotation took effect. Verify the old credential no longer authenticates (attempt a call with it against the provider and expect a rejection) before moving on.
- Assess blast radius. What could the credential access? Check provider-side access logs for any usage between the leak and the rotation.
- Only then, clean up the repository history — and only if you have a specific reason to (compliance requirement, public repo, concern about the raw string persisting in search indexes or CI logs).
Rewriting history with the credential still valid protects nobody: the value is already out, cached in forks, CI logs, and anyone’s local clone from before the cleanup. Teams that spend the first two hours after a leak wrestling with git filter-repo instead of rotating are optimizing for the wrong risk.
Blast-radius assessment deserves more than a quick glance at a dashboard. For a cloud provider key, pull the account’s activity log for the entire window between the earliest commit that could have exposed the secret (not just when it was noticed) and the moment rotation completed — a scanner or bot finding a leaked key in a public repo can act within minutes, well before a human notices the same leak internally. For a database credential, check for new connections from unfamiliar source IPs, unexpected schema changes, or export-shaped query patterns during that window. Document what you found either way; “we checked and saw nothing” is a meaningfully different statement from “we didn’t check,” and an auditor or incident report will ask which one it was.
Reducing How Often This Happens: Secret Managers
Detection and rotation matter because secrets end up in source control despite best intentions, but the sturdier long-term fix is removing the reason a developer would ever type a real credential into a file in the first place. A secrets manager (HashiCorp Vault, AWS Secrets Manager, or even a well-scoped .env injected at deploy time from an encrypted store rather than committed) means the values that would leak are references and short-lived tokens, not the underlying credential itself. This doesn’t replace the scanning and rotation discipline above — secrets still end up in the wrong place through misconfiguration and human error regardless of tooling — but it shrinks both how often it happens and how much damage a single leak does.
Rewriting History With git filter-repo
If a cleanup is warranted, git filter-repo (the officially recommended replacement for the old git filter-branch and BFG Repo-Cleaner, both of which are slower and have known edge-case bugs) rewrites every commit that touched the offending file or string.
# Install (Python-based, single file)
pip install git-filter-repo
# Remove a specific file from all of history
git filter-repo --path config/secrets.yml --invert-paths
# Or replace a specific leaked string everywhere it appears
echo 'AKIAABCDEFGHIJKLMNOP==>REDACTED_AWS_KEY' > replacements.txt
git filter-repo --replace-text replacements.txt
git filter-repo operates on a fresh clone by default and refuses to run on a repo with unpushed local changes or extra remotes configured, which is a deliberate safety rail — read the tool’s own warnings rather than working around them.
Force-Push Coordination
Rewriting history changes every commit hash downstream of the change, which means every existing clone and fork is now diverged. This step needs explicit coordination, not a silent force-push on a shared branch.
# After filter-repo rewrites history locally
git remote add origin [email protected]:furybee/example.git
git push origin --force --all
git push origin --force --tags
Before that push: notify every collaborator that they must discard and re-clone (or carefully rebase, which is error-prone enough that re-cloning is usually the safer instruction), invalidate any CI caches keyed on commit SHAs, and check whether any deployed artifact records a commit hash that will no longer resolve to the same tree. On a protected branch, this also requires temporarily lifting force-push restrictions, which should itself go through the same change-approval process as any other protected-branch exception — not a quiet toggle during an incident.
Comparing the Tools
| Tool | Stage | Approach | Best used for |
|---|---|---|---|
| gitleaks | pre-commit, CI | Regex + entropy pattern matching | Fast local feedback, broad format coverage |
| trufflehog | CI, historical scan | Pattern matching + live credential verification | Cutting false positives, one-time full-history audits |
| GitHub push protection | Server-side, pre-receive | Pattern matching, blocks the push itself | Preventing the leak from ever landing |
| git filter-repo | Post-incident cleanup | Rewrites commit history | Removing the string from history after rotation |
Key Takeaways
- A secret committed to git should be treated as permanently compromised the moment it’s pushed, regardless of how fast it’s removed from the branch tip.
- Pre-commit scanners (gitleaks) catch mistakes locally but are bypassable; CI scanning and server-side push protection are the actual control.
- GitHub push protection (and equivalents on GitLab/Bitbucket) rejects the push before the secret ever reaches the remote — enable it organization-wide if available.
- The moment a secret leaks: rotate first, confirm the old credential is dead, assess what it could have accessed, and only then consider rewriting history.
- Rewriting git history with
git filter-reponever substitutes for rotation — the leaked value is already out regardless of what the repo’s history says afterward. - A history rewrite changes every downstream commit hash; coordinate a force-push explicitly rather than doing it silently on a shared branch.
- Run a full historical scan (not just new commits) at least once with a tool like trufflehog’s verified-credential mode — old, forgotten leaks are common and often still valid.