Secrets in git history

Finding, purging, and why you rotate anyway.

Beginner25 min · lesson 4 of 13

Git is a ledger, not a whiteboard. A whiteboard you wipe. A ledger keeps every line anyone ever wrote in it, and every clone of a repository carries a full copy of that ledger back to page one. A secret is any string that opens a door: an API key (the password one program uses to call another program), a database password, a TLS private key (the file that proves your server really is your server), a cloud token. Commit one, even for five minutes, even in a private repo, and you have written your bank PIN into a notarized logbook. You can glue a blank sheet over the page tomorrow. Anyone who flips back one page still reads the PIN.

The clock on this is measured in minutes. Bots watch GitHub's public event stream the moment it updates. Researchers who plant decoy AWS (Amazon Web Services) keys in a public commit have seen the first unauthorized API call land in under ten minutes. GitGuardian, a company that scans public code for credentials, counted over twenty million fresh secrets pushed to public GitHub in a single year, and a lot of that shows up in individual developers' personal repositories rather than company accounts. A private repo protects you less than it feels like it does. Repositories get forked into contractor accounts, opened up during acquisitions, and cached on laptops that later get stolen. One leaked cloud key pays for somebody else's five-figure cryptomining bill, or walks out with your customer data.

Deleting the file changes nothing

Three git words explain why. A blob is the raw content of a file, stored once and filed under a hash of that content, like a photocopy in a numbered drawer. A tree is the index card listing which filenames point at which drawers, so a tree is a snapshot of one directory. A commit points at one tree plus the commit that came before it. Run git rm on a file and commit, and git writes a *new* index card that leaves the filename off. The old card and the secret's photocopy stay in the drawer, still reachable through the parent commit. Git is append-only on purpose. Nothing you write today overwrites what you wrote yesterday.

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". git show <commit>:<path> reads a file straight out of git's object storage, with no checkout and no branch switching. git log -S, nicknamed the pickaxe, finds every commit that ever added or removed a given string. Anyone with read access can run all three. So can anyone who cloned the repo at any point in the past, and that is the detail that changes your whole plan. Rewriting history repairs *your* copy. It does nothing to the copies already sitting on laptops, CI (continuous integration) runners, and forks.

Find out what you have already leaked

Hunting with the pickaxe works when you already know the string you are looking for. It falls apart the moment you don't. Secret scanners read every blob in every commit and match two signals. First, patterns: AWS access keys start with AKIA, GitHub tokens start with ghp_. Second, entropy, which is a score for how random a string looks. English prose scores low because letters follow predictable neighbors. wJalrXUtnFEMI… scores far higher, because it looks like dice. That second signal catches credentials that fit no vendor's shape at all. gitleaks is the open-source scanner most teams reach for, and one command reads the entire history of the repo you are standing in.

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 survivable on a repo with years of history behind it. Export today's findings once with --report-path, then feed them back in with --baseline-path. Now the pipeline fails on *new* leaks only, instead of shouting about the same old ones every single run. To decide what to fix first, TruffleHog goes a step further. trufflehog git file://. --results=verified takes each candidate credential and actually calls the provider's API with it, then reports only the ones that still work. Those are the phone calls you make first.

Rotate first, rewrite second

Here is the line between real incident response and theater: rotation, not deletion, is the fix. Rotating a credential means killing it where it was issued and creating a replacement. Deactivate the key in AWS IAM (identity and access management, the service that hands out AWS credentials). Regenerate the token in your GitHub settings. Once a secret has been pushed anywhere, treat it as copied. Scrubbing the history afterward is housekeeping. It quiets your scanners and stops future readers, and it does nothing about whoever already has the value. The order 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 git@github.com: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 git@github.com: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 into every commit built on top of it. Open pull requests break. Signed commits lose their signatures. Every teammate has to delete their clone and pull a fresh one, because a stale clone that pushes will drag the old history straight back in. Announce a short freeze window before you force-push, which is git's way of overwriting the branch on the server with a history that no longer lines up with it. If a file should never have existed at all, git filter-repo --invert-paths --path .env is the blunt instrument, and it strips that file out of every commit in the repo.

A force-push does not clean the platform
GitHub and GitLab hold on to unreachable ("dangling") commits after a force-push, and anyone who knows the hash can still fetch them. Every fork keeps its own copy of the objects too. Pulling a secret back out of public view takes a support request, and GitHub runs a dedicated sensitive-data-removal process for exactly this. Even after all of that, you cannot un-leak a value somebody already read. Rotation stays the one step an attacker cannot undo.

Stop the next one before it lands

Prevention takes two layers, because each one leaks on its own. On your machine, a pre-commit hook is a small script git runs in the half second before it records a commit. It reads your staged changes and refuses the commit when it finds a credential. Hooks are polite, though, not binding. They live outside version control until someone installs them, and git commit --no-verify walks right past them. The server side is the real backstop. GitHub push protection rejects a push carrying a high-confidence pattern before it ever lands, and a gitleaks step in CI exits non-zero on a finding, which is how a command tells the pipeline it failed. That covers everything push protection does not recognize. The pre-commit framework handles the client half in a few lines of 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 where this breaks. Pattern rules cannot see a secret with no recognizable shape, and a database password like hunter2 has neither high entropy nor a vendor prefix. Entropy rules fail in the other direction and flag test fixtures and minified assets (files squashed down to save bandwidth, which look like noise), so plan on maintaining an allowlist in .gitleaks.toml. Rescanning a large monorepo's full history on every pipeline run is also far too slow to live with. Scan the history once, save that result as your baseline, then scan only the new commits from there on (--log-opts accepts a commit range).

A clean history solves the problem of secrets sitting at rest in your repo. It says nothing about where credentials should live once the application is actually running. For most teams that runtime is Kubernetes, and a Kubernetes Secret object is base64-encoded, not encrypted. Base64 is an encoding, a reversible way of writing raw bytes as safe text. There is no key involved and no protection of any kind, so anyone who can read the object can read the secret. The gap between what a Kubernetes Secret protects and what it appears to protect is the next lesson.

Say someone cleans up the history on a Saturday and force-pushes without telling the team. Monday morning a colleague with a week-old clone merges a feature branch, and the secret blob is back in the repo. Announce the freeze. Require fresh clones. Close or rebuild the open pull requests that still point at the old commits. The operational bill for a rewrite is real, which is one more reason rotation goes first. A flawless rewrite still cannot save a key that already left the building.

Private repositories fail quietly. Acquisitions export archives. Laptops get imaged for support. CI runners cache checkouts for weeks. "We never made it public" and "nobody else has a copy" are two different sentences. Treat every push of a high-value credential as a possible leak, and design so those credentials are short-lived enough that a three-week-old finding is already a dead key.

A pre-commit hook only protects the people who installed it, so pair it with server-side enforcement and stop counting the hook as a control on its own. Write down how someone requests an allowlist entry for a deliberate test fixture, then re-read that allowlist every quarter. Stale exceptions are how a real key sneaks back in wearing a # noqa costume.

When you brief leadership after a leak, open with rotation timestamps and blast radius (which systems that key could reach), not with how clean the filter-repo output looked. Executives hear "we rewrote git history" and file it under solved. Your job is to say the old credential is dead, say when it died, list the systems still waiting for the replacement to be wired in, and put a name and a date on the clone and fork cleanup.

Try this

Run this in a disposable repo, nothing you care about. Commit a fake key, "remove" it the naive way, then watch it come back one command later. Stop before you force-push anything to a shared remote.

terminal
mkdir -p /tmp/sf-git && cd /tmp/sf-git && git init -q
echo "AKIAIOSFODNN7EXAMPLE" > .env && git add .env && git commit -qm "add env"
git rm .env && git commit -qm "remove credentials"
git show HEAD~1:.env
gitleaks git . --no-banner 2>&1 | head -15
output
AKIAIOSFODNN7EXAMPLE
Finding: AKIAIOSFODNN7EXAMPLE
RuleID: aws-access-token
Commit: <hash of first commit>
WRN leaks found: 1
# "removed" in HEAD — still in history and still flagged

Takeaway

Git is a ledger. Deleting a file writes a new page, it does not erase an old one. Rotation is what contains the incident. Rewriting history is housekeeping for your remotes and your teammates.

Next: turn on push protection or a gitleaks gate in CI for every repo that can hold a credential, and keep a baseline file so old noise cannot hide a new leak.

Quick check
01An AWS key was committed and pushed to your repo three weeks ago. Which single action actually *contains* the exposure?
Correct — Rotation is the one move an attacker cannot undo. Once the key was pushed, assume the value is already copied.
Incorrect — No. A rewrite fixes your copy only. Clones, forks, and dangling commits still hold the old value, so this is housekeeping, not containment.
Incorrect — No. Git history is append-only. The old blob sits one commit back (git show HEAD~1:.env) and git log -S finds it in seconds.
Incorrect — No. Even a successful purge cannot un-leak a value someone already copied. It is the last cleanup step, not containment.
02You ran git filter-repo and force-pushed. Why can the secret still be reachable?
Incorrect — No. filter-repo rewrites history. It does not encrypt dangling objects.
Incorrect — No. Forks and local clones keep the old objects until someone re-clones them.
Correct — A rewrite moves your branch tip. It cannot un-copy bytes that already live somewhere else.
Incorrect — No. Dangling commits usually stay fetchable by hash until support purges them.
03Your CI job runs gitleaks git . --verbose on every push. For a month it has failed the same way: eleven findings, all from old commits whose keys were rotated long ago, and the log still ends with WRN leaks found: 11. What do you do next?
Correct — Every finding carries a stable Fingerprint, and that is what a baseline matches on. The known noise stops shouting and the job still fails the moment a twelfth, genuinely new leak lands.
Incorrect — No. The allowlist is for deliberate test fixtures and for the false positives entropy rules throw at minified assets. Stale exceptions are how a real key sneaks back in wearing a # noqa costume.
Incorrect — No. Push protection only rejects high-confidence patterns. The CI step is what covers everything push protection does not recognize, so deleting it widens the gap rather than closing it.
Incorrect — No. That is a full rewrite — broken pull requests, a freeze window, every teammate re-cloning — spent on keys that are already dead. It is an expensive answer to a reporting problem.

Related