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.

Beginner25 min · lesson 2 of 5

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.

shell
# a secret was committed, then "removed" the naive way
git rm .env
git 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.

shell
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:

Leaked-secret incident response
1Detect
gitleaks / TruffleHog full-history scan
2Rotate
revoke at the issuer, deploy replacement
3Rewrite
git filter-repo in a fresh clone
4Force-push
every collaborator re-clones
5Purge the platform
support ticket for cached commits
Rotation is step 2, not step 5 — everything after it is cleanup, not containment.
shell
# git-filter-repo is the maintained replacement for filter-branch and BFG
pip install git-filter-repo
# work in a fresh clone — filter-repo refuses to run on a non-pristine repo
git clone [email protected]:acme/payments-api.git && cd payments-api
# map every leaked value to a placeholder
cat > /tmp/expressions.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>REDACTED
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>REDACTED
EOF
git 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-push
git remote add origin [email protected]:acme/payments-api.git
git 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.

Force-pushing does not purge the platform
GitHub and GitLab keep unreachable ("dangling") commits fetchable by hash after a force-push, and every fork holds its own copy of the objects. Getting a secret out of public view requires a support request — GitHub has a dedicated sensitive-data-removal process — and even then, you cannot un-leak it. Rotation is the only step an attacker can't undo.

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:

shell
cat > .pre-commit-config.yaml <<'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.28.0
hooks:
- id: gitleaks
EOF
pre-commit install
# -> pre-commit installed at .git/hooks/pre-commit
echo 'token = "ghp_x7GbQ29LkMnPqRsTuVwXyZ0123456789AbCd"' >> app.py
git 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.

Quick check
01An AWS key was committed and pushed to your repo three weeks ago. Which single action actually *contains* the exposure?
Correct — the lesson's core rule: rotation is the only step an attacker can't undo; once pushed, assume the secret is already copied.
Incorrect — rewriting only fixes your copy; clones, forks, and dangling commits still hold the secret, so it's hygiene, not containment.
Incorrect — history is append-only; the old blob stays reachable one commit back (git show HEAD~1:.env) and via git log -S.
Incorrect — even a successful platform purge can't un-leak a copied secret; it's the final cleanup step (5), not containment.

Related