Secrets in git history
Finding, purging, and why you rotate anyway.
In short. Secrets in git history: find them, purge the trail, and rotate anyway because copies linger.
Git is a ledger, not a whiteboard. Every commit is written in permanent ink, and every clone of a repository carries a complete copy of that ledger back to the first page. A secret is any string that grants access to something — an API key, a database password, a TLS private key, a cloud token. Committing one to git, even for five minutes, even in a private repo, is like writing your bank PIN in a notarized logbook: you can glue a blank sheet over it tomorrow, but anyone who flips back one page reads the PIN. This lesson covers how secrets end up in history, why deleting them changes nothing, and the exact commands to find, purge, and prevent them.
The stakes are measured in minutes, not months. Automated scanners watch the public GitHub event stream in real time; researchers who plant canary AWS keys in a public commit see the first unauthorized API call in under ten minutes. GitGuardian's annual audit counts over twenty million new secrets pushed to public GitHub in a single year — much of it surfacing in individual developers' personal repositories rather than corporate organization accounts. "Private" is thin protection too: repos get forked to contractor accounts, opened during acquisitions, and cached on stolen laptops. One leaked cloud key funds a five-figure cryptomining bill or a full data exfiltration.
Why deleting the file doesn't work
Three git terms explain the failure. A blob is file content, stored once and addressed by a hash of that content. A tree maps filenames to blobs — a snapshot of a directory. A commit points to one tree plus its parent commits. When you git rm a file and commit, git writes a *new* tree that omits the file; the old tree and the secret's blob stay in the object database, reachable through the parent commit. History is append-only by design — nothing you do going forward overwrites what came before.
# a secret was committed, then "removed" the naive waygit rm .envgit commit -m "remove credentials"# one commit back, it is still fully readable:git show HEAD~1:.env# -> AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE# -> AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY# and searchable across every branch of history:git log --all --oneline -S 'AKIA'# -> 9f3c2e1 add environment config
HEAD~1 means "one commit before the current tip", and git show <commit>:<path> reads a file straight out of the object database — no checkout required. git log -S (the "pickaxe") finds every commit that ever added or removed a string. Anyone with read access can run these. So can anyone who cloned the repo at any point in the past — which is the detail that changes everything: rewriting history fixes *your* copy, not the copies already sitting on laptops, CI runners, and forks.
Audit what you've already leaked
Manual pickaxe searches don't scale past one known secret. Secret scanners walk every blob in every commit and match two signals: patterns (AWS access keys start with AKIA, GitHub tokens with ghp_) and entropy — a measure of randomness. wJalrXUtnFEMI… scores far higher than English prose, which catches generic credentials that fit no vendor pattern. The de facto open-source standard is gitleaks; one command scans the full history of the current repo.
gitleaks git . --verbose# Finding: AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE# Secret: AKIAIOSFODNN7EXAMPLE# RuleID: aws-access-token# Entropy: 3.684184# File: .env# Line: 1# Commit: 9f3c2e1d8a4b...# Author: Priya N.# Date: 2026-07-08T14:22:31Z# Fingerprint: 9f3c2e1d8a4b:.env:aws-access-token:1## 3:41PM INF 47 commits scanned.# 3:41PM INF scanned ~182 KB in 96ms# 3:41PM WRN leaks found: 1
The Fingerprint line is what makes this workable at scale: export today's findings once with --report-path, then pass them back with --baseline-path so pipelines fail only on *new* leaks instead of drowning in years of known ones. For triage priority, TruffleHog adds live verification — trufflehog git file://. --results=verified actually calls each provider's API with the candidate credential and reports only the ones that still work. Those get rotated first.
Rotate first, rewrite second
Here is the rule separating real incident response from theater: rotation, not deletion, is the fix. Rotating a credential means revoking it at its issuer and generating a replacement — deactivate the key in AWS IAM, regenerate the token in GitHub settings. Once a secret has been pushed anywhere, assume it has been copied; scrubbing history afterward is hygiene (it silences scanners and stops future readers), not containment. The order of operations matters:
# git-filter-repo is the maintained replacement for filter-branch and BFGpip install git-filter-repo# work in a fresh clone — filter-repo refuses to run on a non-pristine repogit clone [email protected]:acme/payments-api.git && cd payments-api# map every leaked value to a placeholdercat > /tmp/expressions.txt <<'EOF'AKIAIOSFODNN7EXAMPLE==>REDACTEDwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>REDACTEDEOFgit filter-repo --replace-text /tmp/expressions.txt# -> Parsed 47 commits# -> New history written in 0.14 seconds...# -> Completely finished after 0.31 seconds.# filter-repo strips 'origin' as a safety measure; re-add it and force-pushgit remote add origin [email protected]:acme/payments-api.gitgit push origin --force --all && git push origin --force --tags
Every rewritten commit gets a new hash, and the change cascades to all descendants: open pull requests break, signed commits lose their signatures, and every collaborator must delete their clone and re-clone — a stale clone that pushes will resurrect the old history. Announce a short freeze window before you push. If an entire file should never have existed, git filter-repo --invert-paths --path .env is the blunter instrument: it removes the file from every commit.
Stop the next one at commit time
Prevention needs two layers, because each fails alone. Client-side, a pre-commit hook — a script git runs before recording a commit — scans staged changes and blocks the commit on a finding. But hooks are advisory: they live outside version control unless you install them, and git commit --no-verify skips them entirely. Server-side scanning is the backstop: GitHub push protection rejects pushes containing high-confidence patterns before they land, and a gitleaks step in CI (it exits non-zero on findings) fails the pipeline for everything else. The pre-commit framework makes the client half a two-line config:
cat > .pre-commit-config.yaml <<'EOF'repos:- repo: https://github.com/gitleaks/gitleaksrev: v8.28.0hooks:- id: gitleaksEOFpre-commit install# -> pre-commit installed at .git/hooks/pre-commitecho 'token = "ghp_x7GbQ29LkMnPqRsTuVwXyZ0123456789AbCd"' >> app.pygit add app.py && git commit -m "wip"# -> Detect hardcoded secrets.................................Failed# -> - hook id: gitleaks# -> - exit code: 1
Know the limits. Pattern rules miss secrets with no recognizable shape — a database password like hunter2 has low entropy and no vendor prefix — while entropy rules false-positive on test fixtures and minified assets, so expect to maintain an allowlist in .gitleaks.toml. And rescanning a large monorepo's entire history on every pipeline run is too slow: scan history once, record the baseline, then scan only new commits (--log-opts accepts a commit range) from there on.
History hygiene solves the secret-at-rest-in-your-repo problem, but it says nothing about where credentials should live once the application actually runs. For most teams that runtime is Kubernetes — whose Secret objects are base64-encoded, not encrypted, and shared more freely than the name implies. That gap between what Kubernetes Secrets protect and what they merely appear to protect is the next lesson.
git show HEAD~1:.env) and via git log -S.