Home › Security › Secrets in Git History — Purge Leaks and Rotate Keys
Beginner 5 min · September 23, 2026

Secrets in Git History — Purge Leaks and Rotate Keys

Committed AWS keys and tokens live forever in git history.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 15 min
  • ✓A git repo you own (for practicing hooks and scans safely)
  • ✓Basic comfort with branching, pushing, and force-push risks
  • ✓An account on your git forge for push-protection settings
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Deleting a committed secret in a new commit doesn't remove it: it stays in history, clones, forks, and CI logs forever
  • Rotate first, purge second: rewriting history alone leaves the old secret valid everywhere it was copied
  • Rewrite history with git filter-repo or BFG, force-push, expire caches, and treat every clone as still compromised
  • Block future leaks with pre-commit hooks (gitleaks, detect-secrets) plus server-side push protection on the forge
  • Keep secrets in environment variables and a secret manager, referenced by name — never written into files or commands
✦ Definition~90s read
What is Secrets Leaked in Git History?

Committing a secret means storing a credential — API keys, cloud access keys, private tokens, certificates, passwords — inside a git object where it becomes part of the repository's permanent history. Every clone, fork, bundle file, and CI checkout carries it.

★
Writing a password in a library book's margin, then scribbling it out, doesn't work — every photocopy ever made still shows it.

Removing the secret from the working tree (or even from the current HEAD) leaves every historical commit intact, and git's content-addressed design means anyone with the old commit hash can still fetch the blob from most forges until caches expire and objects are garbage-collected.

The exposure surface is wider than the repo. CI logs, error trackers, container layers, laptop clones, and forks all carry the secret beyond the repo's access controls. This is why 'rewrite alone is not enough' is the cardinal rule: purging history cleans your canonical repo, but every copy already taken stays valid until the credential itself is rotated and revoked.

The correct response sequence is rotate, purge, verify. First revoke the credential and issue a replacement, auditing what the old one touched. Then rewrite history to remove the secret from all commits, force-push, expire forge caches, and prune reflogs.

Then verify with a fresh clone and a history-wide scan that no copy of the secret pattern remains — and notify holders of other clones that their copies are tainted.

Prevention replaces discipline with machinery. Pre-commit hooks (gitleaks, detect-secrets) scan staged changes before a commit is even created; forge push protection blocks known secret formats at upload time; and secret managers (Vault, cloud secret stores) plus environment-variable injection mean there's no credential in the working tree to commit in the first place.

Scanning existing repos catches secrets committed before the gates existed.

Plain-English First

Writing a password in a library book's margin, then scribbling it out, doesn't work — every photocopy ever made still shows it. Git history is that photocopier: each commit is a snapshot, and 'deleting' a secret just adds a new snapshot where it's gone while all older snapshots keep it readable. Worse, everyone who borrowed the book (cloned the repo) has their own copies. The only real fix is changing the lock the password opened, then recalling every copy you can.

It takes five seconds: export AWS_SECRET_ACCESS_KEY=... pasted into the wrong terminal, committed with -a, pushed before coffee. The key now lives in your repo — not just in the current files, but baked into a commit object that git will faithfully preserve, copy to every clone, and serve to every fork forever. Deleting the line in the next commit changes nothing about the copies already distributed.

Attackers know this better than most developers. Bots watch public push events and test fresh credentials within seconds — studies of honeytoken leakage have repeatedly shown exploitation starting under a minute after exposure. Private repos aren't safe either: contractors' clones, CI logs, error trackers, and backup snapshots all carry the secret beyond the repo's access controls.

Recovery has a strict order that teams keep getting backwards. Rotation comes first, because a purged-but-still-valid key is still a breach. History rewriting comes second, with git filter-repo or BFG, followed by cache expiry and clone notification. Prevention comes third and lasts: pre-commit hooks, push protection, and secret managers that keep credentials out of files entirely. This guide walks all three in that order.

How Secrets End Up Committed (and Why Delete Isn't Enough)

Secrets enter repos through boring, hurried paths: a .env file added with git add -A, a debug line printing a token left in a commit, an AWS key pasted into a config example, a private key vendored 'temporarily' for a deploy script. The commit that carries them usually isn't malicious or even careless in intent — it's a Friday-evening -a flag or a tutorial-following afternoon. The damage comes from git's memory, not the author's morals.

Git remembers everything by design. Each commit snapshots the full tree, objects are content-addressed and deduplicated, and history is the product. Deleting the secret in a later commit adds a new snapshot without it; every prior snapshot keeps it, fetchable by hash, copied into every clone and fork, cached by forges and CI systems. 'But I deleted it Saturday' is the sentence every incident review hears — and it's always followed by the log line showing exploitation started Friday.

Internalize the mental model: pushing a secret is publishing it, with a delay before you notice. Private repos only narrow the audience to everyone with past, present, or indirect access — contractors, CI vendors, error-tracking services, backup operators. The recovery chapters below assume the worst audience and work backward to safety; this chapter's only job is killing the comforting fiction that deletion equals recall.

📊 Production Insight
A team 'fixed' a committed key by deleting the file and pushing. Six months later an audit found the key still valid and still working — nobody had ever rotated it. Deletion without rotation is decoration.
🎯 Key Takeaway
Every commit is a permanent snapshot distributed to clones and forks. Deleting a secret hides it from HEAD while history keeps serving it.

Rotate First: Why Rewriting Alone Is Never Enough

Rotation means killing the exposed credential at the provider and issuing a replacement: deactivate the IAM key (deactivation beats deletion for audit trails), revoke the OAuth token, roll the password, reissue the certificate. Do this before any git surgery, because every minute the old secret stays valid is a minute someone's clone can spend it. The exposure window runs from commit time to revocation time — measure it, record it, and scope your audit to it.

Rotation alone isn't enough either, and that's the point of the sequence. Revoking stops future abuse but leaves the secret readable in history for anyone who finds the old commit — a future attacker with repo access, a leaked backup, a forgotten fork. So purge after rotating: history rewriting removes the readable copy so the incident can't recur from the same artifact, even though rotation already defanged it.

Audit what the old credential touched between exposure and revocation. Provider audit logs (CloudTrail, Azure Activity, token-use logs) show every action by the exposed identity: instances launched, data read, permissions changed. Review each action as potentially hostile, destroy what it created, and rotate anything it could have read — because a key that listed your other secrets compromises them transitively. Rotation first, purge second, audit always.

📊 Production Insight
A team rewrote history beautifully over a weekend but rotated on Monday 'to avoid breaking deploys.' The key was abused all weekend through a fork they'd never heard of. Rotation delayed is rotation denied.
🎯 Key Takeaway
Revoke the credential before touching git, then purge history, then audit everything the old secret touched during its exposure window.

Rewriting History with git filter-repo and BFG

git filter-repo is the modern rewriting tool: it purges files or patterns from every commit across all branches and tags, strips the secret from history, and leaves you with a clean repo to force-push. BFG Repo-Cleaner fills the same role with a simpler interface that's faster on enormous monorepos. Both rewrite commit hashes (unavoidably — history is the hashes), so coordinate the force-push: freeze merges, land in-flight PRs or rebase them after, and announce the hash change so nobody 'repairs' it by pushing the old history back.

The mechanics matter less than the completeness. Purge every branch, every tag, and stashed refs — secrets hide in release tags and ancient feature branches that HEAD-focused cleanup misses. After force-pushing, expire the forge's cached views and prune reflogs; GitHub and peers keep unreachable objects around until garbage collection, and cached file views can serve the secret for hours. Then tell every collaborator to re-clone fresh: a pull into an old clone keeps the tainted objects locally.

The snippet below shows the canonical filter-repo purge for a file, plus the verification scan that must follow. Treat forks as unrecoverable — contact their owners, rotate anyway, and accept that copies beyond your control are why rotation came first. A rewritten canonical repo plus revoked credentials is the complete recovery; either half alone is theater.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
# Purge a secret file from ALL history (coordinate freeze + force-push first)
git filter-repo --invert-paths --path .env --force

# Or strip a secret pattern from every file in every commit:
# git filter-repo --replace-text <(echo 'AKIAIOSFODNN7EXAMPLE==>REDACTED') --force

# Push the rewritten history and expire the forge cache
git push origin --force --all && git push origin --force --tags

# Verify with a FRESH clone: no trace of the pattern anywhere
cd /tmp && git clone <repo-url> verify-clone && cd verify-clone
gitleaks detect --source . --no-git -v
📊 Production Insight
A rewrite cleaned main but left the secret in a v2.1 release tag. A compliance scan found it a year later and reopened the whole incident. Purge all refs — branches, tags, stashes — or don't bother starting.
🎯 Key Takeaway
Purge every ref with filter-repo or BFG, force-push with coordination, expire caches, and make collaborators re-clone fresh.

Pre-commit Hooks That Block the Next Leak

Prevention beats response by orders of magnitude, and pre-commit hooks are the cheapest prevention available. Tools like gitleaks and detect-secrets scan staged changes before a commit object exists: a match blocks the commit with the offending line highlighted, and the secret never enters history at all. Installation is one config file plus one hook-install command; the whole team inherits it from the repo template.

Layer server-side protection behind the local hook, because local hooks can be skipped with --no-verify and contractors may never install them. Forge push protection (GitHub's secret scanning push protection, GitLab's secret push protection) rejects pushes containing known secret formats even when local gates were bypassed. Treat the two as belt and suspenders: hooks catch mistakes instantly at the keyboard, push protection catches everything else at the door.

Tune the signal to survive contact with developers. Baseline existing repos so historical secrets don't block every commit, allowlist test fixtures and documented examples explicitly (with review), and keep scans fast — a hook slower than three seconds gets uninstalled by lunch. The config below wires both scanners into pre-commit: gitleaks for broad pattern coverage, detect-secrets for entropy-based finds. Review hook bypasses (--no-verify usage) in CI to catch the bypassers.

.pre-commit-config.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
12
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.24.2
    hooks:
      - id: gitleaks  # blocks commits carrying known secret patterns
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']
        # Update the baseline after reviewed rotations:
        # detect-secrets scan --baseline .secrets.baseline
📊 Production Insight
A team installed hooks but never enabled push protection; a contractor's --no-verify push landed a key that sat for months. Either layer alone fails open — run both, and audit bypasses.
🎯 Key Takeaway
Scan staged changes locally with gitleaks and detect-secrets, enforce push protection server-side, and keep hooks fast and baselined.

Environment Variables and Secret Managers Done Right

The deepest fix is structural: keep secrets out of the working tree so there's nothing to commit. Applications read credentials from environment variables injected at runtime; the values live in a secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) and reach processes through the orchestrator — never through files, chat, or tickets. A repo that contains only secret names (DB_PASSWORD_REF, not the password) can't leak credentials it never held.

Do the boring parts rigorously. .env files stay gitignored from project birth (add them to global gitignore templates, not just each repo), example files ship with placeholder values that fail loudly if used, and CI injects secrets from the manager into job environments rather than checking in service keys. Prefer short-lived, dynamically generated credentials — database passwords minted per deploy, cloud sessions lasting hours — so any single leak expires on its own.

Mind the new leak vectors this creates. CI logs must mask secret values (most platforms do this only for marked variables — mark them), error trackers must scrub config dumps, and docker build args carrying secrets end up in image layers (use secret mounts, not args). The snippet below shows the safe application side: read from the environment, fail fast when unset, never log the value. Structure doesn't eliminate carelessness, but it moves mistakes from 'published forever' to 'failed locally.'

app/config.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
import os

def required_secret(name: str) -> str:
    # Secret arrives via environment from the manager, never from files.
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing required secret: {name} (check env injection)")
    return value

DB_PASSWORD = required_secret("DB_PASSWORD")  # name in repo, value in manager

# Logging the VALUE is a leak; logging presence is safe:
# logger.info("db password configured: %s", bool(DB_PASSWORD))
💡Repos should hold secret names, never secret values
If a credential appears anywhere in your working tree — file, script, command history — it will eventually be committed. Inject values from a manager at runtime and keep only the names in version control.
📊 Production Insight
A team moved secrets to env vars but echoed them into CI logs for 'debugging' for six months. The logs were the leak. Mask secret variables in CI output and scrub them from error reports.
🎯 Key Takeaway
Inject secrets from a manager at runtime, gitignore .env from day one, prefer short-lived credentials, and never log values.

Auditing Repos and CI Logs for Leftovers

Gates protect the future; audits clean the past. Run a historical scan across every repo in the org — all branches, tags, and stashes — because the next committed-secret incident is usually an old one the new hooks would have caught. Prioritize by exposure: public repos first, then repos with broad collaborator lists, ex-contractor access, or mirroring to other forges. Every find triggers the same rotate-purge-verify sequence; there are no minor committed secrets, only unexploited ones.

Extend the audit beyond git objects. Search CI logs for secret fingerprints (key prefixes, not full values), inspect container layers and build caches for baked-in files, review error-tracker payloads for config dumps, and check chat archives where developers paste 'temporary' credentials. Each of these is a copy the history rewrite won't reach — they need their own rotation and deletion pass.

Make auditing continuous, not episodic. Schedule organization-wide secret scanning weekly, alert on new findings like production incidents, and track mean-time-to-rotate as a security metric. The goal isn't a clean scan once — it's a system where a newly committed secret pages someone within the hour. Between pre-commit hooks, push protection, managers, and scheduled scans, leaked secrets become a brief, loud, self-healing event instead of a quiet permanent one.

BASH
1
2
3
4
5
6
7
8
# Org-wide historical scan: all refs, then CI log fingerprints
for repo in $(gh repo list my-org --limit 200 --json name -q '.[].name'); do
  echo "=== $repo ==="
  gh repo clone "my-org/$repo" "/tmp/audit/$repo" -- --bare --quiet
  gitleaks detect --source "/tmp/audit/$repo" --no-git -v
 done

# Never print full secrets in logs: search fingerprints only (e.g. key IDs)
📊 Production Insight
An org scan found a 4-year-old key in an archived repo — still valid, with admin scope, used by a cron job everyone forgot. Archived doesn't mean dead; scan everything, then deactivate what no job claims.
🎯 Key Takeaway
Scan all repos historically and continuously, audit CI logs and artifacts as separate copies, and track rotation speed as a metric.
● Production incidentPOST-MORTEMseverity: high

A Pushed AWS Key Ran Up $18,400 in Miners Before Standup

Symptom
At 9:04 AM Monday, the AWS bill showed $18,400 in weekend EC2 spend across 4 regions — 210 compute instances nobody recognized. CloudTrail showed the first unauthorized API call 47 seconds after the Friday 6:12 PM push that included an AWS secret key in a committed .env file. The key belonged to a developer's personal IAM user with PowerUserAccess, committed accidentally with git commit -a and pushed to a public repository. By the time anyone looked, the key had been used from 9 countries, 38 booster packs of instances had cycled through, and the .env file had been forked 11 times.
Assumption
The developer believed deleting the file Saturday morning fixed it — the repo HEAD was clean by 9 AM Saturday, so they told nobody. The team believed private-repo habits applied: 'it's a small repo, nobody watches it.' In reality the repo was public, credential-harvesting bots scan every public push event in real time, and the Saturday deletion only removed the secret from HEAD while the Friday commit stayed fetchable by hash. No pre-commit hook or push protection was configured on the repo.
Root cause
A .env file containing a live AWS secret key was committed and pushed to a public repo. The key stayed valid all weekend because nobody rotated it — the developer's silent deletion addressed visibility, not validity. Automated harvesters tested the key within a minute of the push and operated for 63 hours, launching and cycling mining instances. Forks and clones taken during the window preserved the secret independently of the later deletion.
Fix
Monday's response took 2 hours: deactivate (not just rotate) the exposed IAM user, audit CloudTrail for everything it touched, terminate all rogue instances, and lock the account's regions down to approved ones with SCPs. The repo history was rewritten with filter-repo to purge the .env file from all commits, caches expired, and the 11 forks were contacted. Prevention landed the same week: gitleaks pre-commit hooks org-wide, forge push protection enabled, .env added to global gitignore templates, and the developer's workflow moved to a secret manager with short-lived credentials.
Key lesson
  • Rotate first, purge second: a deleted-but-valid key is still a breach, and bots harvest public pushes within a minute.
  • Silent cleanup is an incident response failure. Any committed secret triggers rotation, audit, and notification — no matter how fast it was deleted.
  • Pre-commit hooks plus push protection are the minimum bar for every repo; secret managers remove the credential from the tree entirely.
Production debug guideFive steps in strict order: rotation before rewriting, verification before closure.5 entries
Symptom · 01
A secret was just committed — possibly just pushed
→
Fix
Stop and rotate first: revoke the credential at the provider (deactivate the key, revoke the token, roll the password) before touching git. Check whether the push already happened with git status and the forge UI — assume any pushed secret is compromised even if you delete it seconds later. Record the exposure window (commit time to revocation time) for the audit trail.
Symptom · 02
The credential is revoked and you need history cleaned
→
Fix
Rewrite history with git filter-repo (or BFG for very large repos): purge the file or the secret pattern from every commit on every branch and tag, then force-push and ask the forge to expire cached views. Notify all collaborators to re-clone fresh — their old clones still contain the secret and a pull won't remove the tainted objects. Treat forks as permanently tainted copies you can't recall.
Symptom · 03
You need to know how far the secret spread
→
Fix
Audit everywhere the repo or its outputs traveled: CI logs (search for the key prefix or fingerprint, never the full secret), build artifacts, container layers, error trackers, chat transcripts, and backup snapshots. Search CloudTrail or provider audit logs for actions by the exposed identity during the window. Anything the old credential touched gets reviewed; anything it created gets destroyed.
Symptom · 04
History is clean and you want proof, not hope
→
Fix
Verify with a fresh clone: run gitleaks and a pattern scan across all branches, tags, and stashes, and confirm the secret's fingerprint appears nowhere. Check packed-refs and reflogs on the forge side where you have access. Only when the full-history scan is clean — plus rotation confirmed at the provider — do you close the incident.
Symptom · 05
The incident is closed and you want to prevent repeats
→
Fix
Install pre-commit hooks (gitleaks protect plus detect-secrets) in the repo and the org template, enable push protection on the forge, add secret filenames to global gitignore, and move the workflow to a secret manager with short-lived credentials. Run a one-time historical scan across all org repos — the next incident is usually an older secret the new gates would have caught.
Committed-Secret Response at a Glance
Root CauseHow to ConfirmFixPrevention
Live credential in git historyHistory-wide scan finds the secret pattern in past commitsRevoke at provider first, then purge all refsPre-commit hooks plus push protection on every repo
Deleted from HEAD but valid in historyOld commit fetchable by hash; key still authenticatesRotate immediately; treat deletion as cosmeticIncident rule: any commit triggers rotation, no exceptions
Copies in clones, forks, and CI logsFork count, clone traffic, log search for fingerprintsRe-clone notices; cache expiry; log redactionSecret managers so copies never hold values
Secrets living in files by defaultWorking tree contains values instead of referencesMove to env injection from a manager; gitignore .envTemplates with placeholders; short-lived credentials
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
git filter-repo --invert-paths --path .env --forceRewriting History with git filter-repo and BFG
.pre-commit-config.yamlrepos:Pre-commit Hooks That Block the Next Leak
appconfig.pydef required_secret(name: str) -> str:Environment Variables and Secret Managers Done Right
for repo in $(gh repo list my-org --limit 200 --json name -q '.[].name'); doAuditing Repos and CI Logs for Leftovers

Key takeaways

1
Deleting a committed secret hides it from HEAD while history, clones, and forks keep serving it.
2
Rotate the credential before rewriting history; purging removes readability, only rotation removes validity.
3
Purge every ref with filter-repo or BFG, force-push with coordination, and make collaborators re-clone.
4
Block future leaks with pre-commit hooks plus unbypassable server-side push protection.
5
Keep secrets in managers with runtime injection; repos hold names, never values.
6
Scan all repos historically and continuously; audit CI logs and artifacts as separate copies.

Common mistakes to avoid

5 patterns
×

Deleting the secret in a new commit and calling it fixed

Symptom
HEAD looks clean while every historical commit, clone, and fork still serves the valid credential.
Fix
Rotate the credential first, then purge history across all refs, then verify with a fresh-clone scan.
×

Rewriting history without rotating

Symptom
The canonical repo is clean but forks, caches, and harvesters already hold a still-valid key.
Fix
Revoke at the provider before git surgery; purging removes readability, only rotation removes validity.
×

Cleaning main but forgetting tags and stashes

Symptom
Release tags and old branches keep serving the secret months later, found by the next audit.
Fix
Purge every ref — branches, tags, stashes — and verify the full-history scan returns zero.
×

Installing hooks without server-side push protection

Symptom
A single --no-verify push bypasses all local gates and the secret lands in history anyway.
Fix
Enable forge push protection as the unbypassable second layer and audit hook bypasses in CI.
×

Passing secrets through CI logs and image layers

Symptom
Echoed variables, build args, and error dumps create copies that history rewrites never reach.
Fix
Mask secret variables, use secret mounts instead of build args, and scrub tracker payloads.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why doesn't deleting a committed secret fix the leak?
Q02SENIOR
What order do you follow when a secret is committed, and why?
Q03SENIOR
When would you use BFG instead of git filter-repo?
Q04SENIOR
How do pre-commit hooks and push protection complement each other?
Q05SENIOR
Design a secrets workflow where committing a credential becomes nearly i...
Q01 of 05JUNIOR

Why doesn't deleting a committed secret fix the leak?

ANSWER
Deletion only changes HEAD; every prior commit keeps the secret, fetchable by hash and copied to clones, forks, caches, and CI logs. The credential stays valid until rotated, so deletion is cosmetic without revocation plus history purging.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Should I delete or deactivate an exposed IAM key first?
02
Can I just make the repo private instead of purging?
03
How do I handle secrets in forks I don't control?
04
Won't force-pushing rewritten history disrupt the team?
05
Are private repos safe from harvester bots?
06
What belongs in .env.example?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Secrets. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Dependency Confusion in Package Registries
1 / 1 · Secrets
Next
Rate Limiting and Credential Stuffing Defence
→