Secrets in git history
Finding, purging, and why you rotate anyway.
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.
# 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". 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.
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:
# 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 git@github.com: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 git@github.com:acme/payments-api.gitgit 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.
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:
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 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.
mkdir -p /tmp/sf-git && cd /tmp/sf-git && git init -qecho "AKIAIOSFODNN7EXAMPLE" > .env && git add .env && git commit -qm "add env"git rm .env && git commit -qm "remove credentials"git show HEAD~1:.envgitleaks git . --no-banner 2>&1 | head -15
AKIAIOSFODNN7EXAMPLEFinding: AKIAIOSFODNN7EXAMPLERuleID: aws-access-tokenCommit: <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.
git show HEAD~1:.env) and git log -S finds it in seconds.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?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.# noqa costume.