Skip to content
KeyDrift
Scan for free
All posts

Git history: why deleting the file never deletes the key

Git's object model makes removal impossible by design: blobs survive deletion, rewrites, force-pushes, and GC. Rotation is the only real fix.

KeyDrift13 min read

Git history: why deleting the file never deletes the key

For developers who have committed a secret, deleted the file, and pushed the "fix": here is why the key remains recoverable in your repo, every clone, and your host's caches — and why rewriting history is cleanup, not remediation.

Deleting a file that contains a leaked key feels like remediation. It produces a tidy diff — one red block, one commit named "remove credentials" — and the working tree is clean afterward. But version control's core commitment is that history does not change. A delete operation adds a new commit recording the absence of the file; it leaves the old content exactly where it was, reachable by anyone who knows where to look. This is not an edge case or a misconfiguration. It is the system working as specified.

This guide covers the mechanics precisely: what git stores, where deleted content survives, what history rewriting does and fails to do, and why rotation is categorically different from every form of cleanup. The procedures assume command-line git; everything shown runs locally with no dependencies beyond git itself and, for the rewrite section, git-filter-repo.

Deletion is an addition

Git stores content in three layers: blobs hold file contents, trees map names to blobs, commits snapshot a tree plus metadata including the hash of a parent commit. Crucially, blobs are content-addressed by hash — the same file content is always the same blob, deduplicated across the entire repository.

When you git add a file, git creates a blob of its contents immediately. That blob exists from the moment of staging, independent of any commit. Committing references it from a tree; committing a deletion simply references a new tree without it. The original blob persists, anchored by the older commit that still points at it.

You can watch this with a disposable repository. All keys shown are synthetic placeholders in provider format — they authenticate against nothing:

mkdir hist-demo && cd hist-demo && git init -q

# Synthetic placeholder in Stripe test-key format. Not a real key.
echo 'STRIPE_KEY=sk_test_51SYNTHETICPLACEHOLDERdoNOTship' > .env
git add .env && git commit -qm "add config"

rm .env && git commit -qam "remove config"

git log --oneline
# e2f4c10 remove config     <- your hashes will differ
# 9a8b7d2 add config

# Reference the commit by position so this runs as written:
git show HEAD~1:.env
# STRIPE_KEY=sk_test_51SYNTHETICPLACEHOLDERdoNOTship

The file is gone from the working tree and from HEAD, and one argument — a commit hash — restores it byte-for-byte. Anyone with clone access has every historical hash. Nothing about this required sophistication: git log -p alone displays the key in the diff of the "add config" commit.

Reachability extends beyond the obvious. Even if you later rewrite or abandon branches, dangling commits remain findable through reflog entries, through remote refs like GitHub's refs/pull/* for every PR head ever pushed, and — importantly — through direct SHA access: forge platforms serve unreachable objects by hash until garbage collection prunes them. GitHub's own guidance on removing sensitive data states plainly that removed data may remain accessible via cached views and pulls, and directs users to contact support to purge cached data (Removing sensitive data from a repository).

Every clone is an independent backup

The repository on your forge is not the canonical store of history; it is one replica among many. Each of the following holds a full or partial copy of the leaked blob:

  • Forks, which preserve the commit history at fork time and do not follow upstream deletions.
  • Collaborators' clones, on laptops, in containers, in editor workspaces — none notified by a force-push, all still able to checkout the old commit.
  • CI systems, which frequently cache repositories or run on persisted workspaces.
  • Package and artifact registries, where an npm tarball or Docker layer built from a polluted tree embeds the blob independently of git entirely.
  • Forge caches: PR refs, archived repos, API-served historical snapshots.

The consequence for threat modeling is stark: after a push, the set of parties holding the secret is unknown and unknowable. You cannot enumerate clones. You cannot recall forks. This is why the analysis of a git-history leak differs from almost every other storage mistake — the disclosure event is the push, not the discovery.

History rewriting: what it does, what it doesn't

Rewriting tools — git-filter-repo (the officially recommended successor to filter-branch), or the BFG Repo-Cleaner — genuinely remove data. They reconstruct commits so that no tree anywhere in the rewritten history references the offending blob, then expire old refs. On a freshly cloned copy, the key becomes unrecoverable through normal git operations.

Here is the same demo repository, scrubbed:

pip install git-filter-repo

# Tell filter-repo what to replace (literal match).
printf 'sk_test_51SYNTHETICPLACEHOLDERdoNOTship==>REDACTED\n' > replacements.txt

git filter-repo --replace-text replacements.txt --force

git log -p --all | grep sk_test_
# (no output)

Two operational notes: filter-repo intentionally removes the origin remote as a safety measure (re-add it and force-push), and it rewrites every descendant commit, changing all their hashes — collaborators must re-clone, open PRs must be rebased, and release tags must be re-created and re-signed.

What rewriting does not do:

  1. It does not reach existing clones or forks. Their histories still contain the original commits and blobs. Coordinating re-clones across everyone who ever pulled is possible for a two-person team and fiction beyond it.
  2. It does not purge forge-side caches automatically. Unreachable commits on the forge may remain accessible by SHA until platform-level pruning; the GitHub doc cited above is explicit that support intervention is required for cached views.
  3. It does not undo the push. Any consumer of the repo between push and rewrite — including automated ones: scrapers, bots, and services that index public repositories for exactly this content — has had uninterrupted access.

Garbage collection deserves a precise note, because it is often imagined as a timer that eventually saves you. Locally, git gc prunes unreachable objects only after an expiry grace period (two weeks by default for loose objects, longer while reflog entries reference them). On forges, pruning of unreachable objects is managed by the platform and not a remediation guarantee. And none of it matters for copies outside the rewritten repo.

The honest summary: history rewriting is hygiene for the remaining life of the repository, not retrieval of what already escaped.

Rotation is the only remediation that reaches every copy

Cleanup acts on storage. Rotation acts on validity. A rotated key is worthless inside every blob, every clone, every fork, every scraper's database — simultaneously, instantly, regardless of where the copies live. No other action has that reach.

This reframes the decision tree after discovering a secret in history:

  1. Rotate first, always. Before rewriting, before purging, before writing the incident report. The rewrite takes hours; the window it leaves open is unnecessary. Provider-specific procedures: Stripe, Supabase, AWS, GitHub tokens.
  2. Then rewrite if the repository must continue living with a clean history — private repos with compliance obligations, templates others will fork, open-source projects preparing for archival.
  3. Then verify the rewrite took effect everywhere you control, and accept what you cannot control.
  4. Then fix the intake path that let the secret reach a commit: pre-commit scanning, push protection, and placeholder discipline (see how the scanner works and the rules reference).

One corollary surprises people: if the key was already dead — revoked before the commit ever landed, or a synthetic example — history rewriting is optional. The criterion is validity, not embarrassment.

Prevention belongs before the commit

Everything above treats the leak as inevitable; the standard toolkit makes it merely likely. Three controls intercept secrets before they enter history at all:

  • Pre-commit hooks running a scanner over staged files stop the majority of accidental commits at zero cost to workflow speed. Gitleaks and similar tools integrate in minutes; the trade-offs of the major options are compared in the scanner landscape guide.
  • Push protection at the forge level blocks pushes matching known partner secret patterns regardless of local tooling — catching contributors who bypassed hooks. Its coverage and gaps are mapped honestly in the GitHub secret-scanning guide.
  • Placeholder hygiene eliminates the training-data effect: example keys in READMEs and tests teach both humans and code assistants that real-looking keys belong in files. Use visibly synthetic placeholders (like every key in this article) and say so adjacent to the value.

None of these reduce the blast radius of a key that slips through. That job belongs to rotation, backed by detection that watches artifacts after the fact — the reason continuous monitoring exists alongside point-in-time scanning.

Searching history like an investigator

Before rewriting anything, establish what's actually in there. Git ships the search tools; knowing their shapes turns "we think it leaked" into an inventory:

# Diffs that add/remove a specific string anywhere in history:
git log -S 'sk_live_FAKE000000000000000000000' --oneline --all

# Same, but matching by regex (e.g., any live-format secret):
git log -G 'sk_live_[0-9a-zA-Z]{24,}' --oneline --all

# Brute sweep: grep every blob in every commit (slow, thorough):
git rev-list --all |
  while read sha; do
    git grep -E 'sk_live_[0-9a-zA-Z]{24,}|AKIA[0-9A-Z]{16}' "$sha" -- 2>/dev/null
  done | sort -u

-S finds commits where the string's occurrence count changed — usually the introducing commit and any deletion theater afterward. -G generalizes to patterns. The brute sweep is O(history) and ugly but answers the only question that matters for scoping: which commits contain which values. Feed those SHAs into the rewrite planning, the exposure-window calculation, and provider-side log review alike (the incident runbook's assessment step).

Two platform-side notes complete the picture. Forge code search indexes default branches, not necessarily full history — absence from web search proves nothing about packfiles. And git log --all covers local refs only; PR refs (refs/pull/*) live server-side and may hold heads your clone never fetched, so fetch those explicitly (git fetch origin '+refs/pull/*/head:refs/remotes/pull/*' on GitHub) before declaring history clean.

Coordinating a rewrite across collaborators

History rewriting fails socially before it fails technically — collaborators' clones, open pull requests, and release tags all hold pre-rewrite state. A workable coordination sequence:

  1. Announce a cutover window, not a suggestion: from time T, all clones are stale; work lands after re-clone only. Small teams absorb this in an hour; larger ones need the announcement to include every long-lived automation credential's migration too.
  2. Land or close open pull requests first. Rewriting under active PRs forks everyone's base history; PRs opened across the rewrite reference commits that cease to exist. Merge what's ready, note what isn't.
  3. Rewrite, force-push, then verify remotely that the offending blobs are unreachable through normal operations on the forge (the jurisdictional limits still apply).
  4. Publish the re-clone instruction set: exact commands (git fetch && git reset --hard origin/main is not sufficient for feature branches; clean clones are), plus token/tag re-creation steps.
  5. Re-create signed artifacts deliberately. Signatures over old commits/tags died with the rewrite; re-signing is part of the operation, not an afterthought — and unsigned interim states deserve explicit acknowledgment in the incident record.

The sequence's cost scales with team size and clone count, which is precisely why rotation-first ordering dominates everywhere: cleanup expense is optional and scheduled; disclosure damage is neither. Teams running this dance once typically invest in prevention immediately afterward — pre-commit scanning and push protection cost less than one coordinated rewrite.

When rewriting is the wrong move

Cleanup enthusiasm has its own failure mode: rewrites performed for keys that are already dead, in repositories whose history carries evidentiary or provenance value. The decision runs through three questions before any coordination cost gets spent:

QuestionIf noIf yes
Is the credential still valid?Rewrite is optional hygiene, not remediationRotation first (always), rewrite secondarily
Does policy require clean history? (templates others will fork, archival, regulated environments)Skip the rewrite; fix forwardProceed with the coordination sequence
Would rewriting destroy investigation material? (active incident, pending disputes)Defer until assessment closesCoordinate with whoever owns the investigation

The first row alone settles most cases. A key revoked months ago that someone discovers in an old commit generates zero urgency — the blob is embarrassing, not dangerous, and rewriting signed-tagged history to hide it spends trust for nothing. Teams that internalize this stop treating history findings as automatic emergencies and start routing them through the same validity-first logic as any other discovery (the runbook's central decision).

The corollary deserves stating for open-source maintainers approached by well-meaning reporters of ancient commits: thank them, verify the credential's current state, respond with facts. History scanning tools sweep public repositories continuously and generate exactly such reports; organizations with a standing answer ("that key was rotated in 2024; here's the rotation record") convert noise into demonstrated competence.

Tags and releases inherit rewrite consequences too: annotated tags reference commit SHAs that cease to exist after filtering, so release pipelines pinning tags break loudly unless tags are recreated over rewritten commits. Inventory tags before rewriting, recreate after, and expect downstream checksum verifiers — package registries, provenance attestations — to notice the change. One more reason the validity-first rule dominates everywhere: dead keys rarely justify renegotiating an entire release chain's integrity story.

Forge-side archaeology supplements local history when scoping exposure windows. Pull-request heads live in server-side refs your clone may never have fetched, and platforms expose them through APIs:

# List PR head SHAs (GitHub):
gh api "repos/{owner}/{repo}/pulls?state=all&per_page=100" \
  -q '.[] | {number, head: .head.sha, title}'

# Fetch every PR ref into a local namespace for scanning:
git fetch origin "+refs/pull/*/head:refs/remotes/pull/*"
git log --all --oneline | wc -l   # includes fetched PR history

Scanning the fetched namespace with the same history sweep covers branches that never merged — a notorious hiding place for abandoned experiments carrying real credentials, invisible in default views but fully present in packfiles anyone could theoretically obtain.

FAQ

If the repo was always private, is history rewriting enough? Private reduces the audience; it doesn't define it. Org members, past members, CI integrations, and any third-party app granted repo access have had clone-grade access. Rewrite if policy demands clean history; rotate regardless.

Does GitHub/GitLab garbage-collect the old commits automatically so I can just wait? Platform-side pruning of unreachable objects is not scheduled remediation, and cached/forked copies exist regardless. Waiting is a decision to leave the blob in place while feeling like it's gone. Rotate; rewrite if warranted; don't wait.

Is BFG or filter-repo the right tool? Both work; git-filter-repo is the tool git's own documentation recommends, handles the replace-text case cleanly, and fails safe around non-fresh clones. BFG remains fine for simple big-file and known-secret deletions.

How do I find secrets in history without reading years of diffs? Run history-aware scanners over the full ref set: gitleaks and trufflehog both accept git-history input and apply format detection far faster than manual review — with the caveat that they only see the repository, which is why artifact-level scanning complements rather than replaces them.


History is immutable; credentials don't have to be. KeyDrift's free scan checks what your deployed build serves — the surface git never sees — in minutes, with no signup.

KeyDrift reads the JavaScript your app actually serves and finds the Supabase, Stripe, OpenAI and AWS keys that should never have left your server. Run a free scan.

KeyDrift is a Veristria product. More about KeyDrift.