A developer commits an AWS access key by mistake, notices within the hour, deletes the line, and pushes a “fix credentials” commit – then closes the ticket assuming the problem is solved. It isn’t. That key still lives in the repository’s history, retrievable by anyone with clone access, and this is exactly the blind spot that git history scanning is built to close.
This article covers how secrets survive deletion inside Git, why a clean working directory tells you nothing about what’s actually exposed, and how to scan, remediate, and prevent this specific class of leak.
Why deleting a file doesn’t delete the secret
Git is designed around immutability. Every commit is a snapshot, and every snapshot before your “fix” commit is still reachable through the ref history. When someone deletes a line containing a password or API key and commits that change, Git doesn’t overwrite the old blob – it just adds a new one and keeps the old one addressable by its commit hash.
Anyone who runs git log -p on that file, or checks out an earlier commit, sees the secret exactly as it was typed. Cloning the full repository pulls every historical object by default, so a contractor who clones the repo six months later gets the same access as someone who was there the day the key was pasted in.
This is the core misconception worth busting: teams treat “we removed it in the next commit” as remediation. It isn’t remediation, it’s concealment. The secret is still live in the object database, still valid unless rotated, and often still indexed by any tool or person that has ever cloned the repository, including forks, CI runners, and local developer machines that never get cleaned up.
Where these secrets actually hide
A few patterns show up repeatedly in incident reviews:
Squashed “oops” commits – a key is added, then immediately followed by a “remove secret” commit, often within minutes. Developers assume speed equals safety. It doesn’t; the window between push and deletion is often long enough for automated scrapers to have already cloned the repo.
Old config files replaced during refactors – a .env.example that once contained a real value before someone genericized it, still sitting in a commit from two years ago.
Merged branches with unsquashed history – a feature branch where a developer debugged locally with a hardcoded database password, then merged without cleaning the branch history.
Force-pushed “fixes” that don’t remove the object – a force-push rewrites the branch pointer, but if the old commit is still referenced by a tag, another branch, or someone’s local clone, the blob remains recoverable, and GitHub in particular caches commits reachable via forks even after a force-push on the origin.
How to scan history instead of just the working tree
Most default secrets scanners only check the current state of files on disk or in a pull request diff. That catches new leaks but misses everything already sitting in prior commits. A proper sweep needs to walk the full commit graph.
Practical approach:
1. Clone with full history (avoid –depth 1, which truncates the log you need to inspect).
2. Run a dedicated history-aware scanner such as gitleaks or trufflehog in full-history mode, which walks every commit rather than just HEAD.
3. Flag high-confidence patterns first – AWS key prefixes, private key headers, Slack tokens, database connection strings – then widen to entropy-based detection for anything that looks like a random secret but doesn’t match a known pattern.
4. Check forks and mirrors, not just the primary repository. A public fork created before the secret was removed keeps its own independent history.
5. Cross-reference findings against your active credential inventory to see which flagged secrets are still valid versus already rotated.
This is exactly the kind of gap covered in more depth in Secrets Scanning Tools Compared: Strengths and Blind Spots – full-history coverage is one of the biggest differentiators between tools that look thorough and tools that actually are.
What to do once a historical secret is confirmed
Rotate first, clean up second. Rewriting history does not change the fact that a secret was exposed – if it left the repository at any point, treat it as compromised and rotate the credential immediately. Rewriting Git history without rotation just hides the evidence while the risk stays live.
Once rotation is done, remove the object from history using git filter-repo (the current recommended tool; BFG Repo-Cleaner is a reasonable alternative for large repos). This rewrites every commit after the exposure point, which changes commit hashes downstream – coordinate with the team before doing this, since every collaborator will need a fresh clone.
Finally, revoke access anywhere the secret might have propagated: CI environment caches, deployment artifacts, forked repositories, and any local clones outside your control. This is the step teams skip most often, and it’s the one that determines whether the incident is actually closed. For credentials specifically tied to cloud infrastructure, the rotation checklist in AWS Credentials in Public Repos: Prevention Best Practices is a useful reference for what “fully rotated” actually means beyond just changing the key value.
Prevention that actually holds up
Pre-commit hooks catch secrets before they’re committed, but they only work if every developer has them installed and enabled – a policy without enforcement is a suggestion. Server-side push protection (rejecting pushes that match secret patterns before they land in history at all) is more reliable because it doesn’t depend on local configuration.
Beyond tooling, the habit that matters most is treating any credential that ever touched a commit – even one immediately reverted – as burned. No exceptions for “it was only there for two minutes.” Continuous monitoring of public repositories and pastes for your organization’s credential patterns closes the gap for the cases that slip past pre-commit controls entirely, which happens more often than most engineering teams assume, especially with source code and intellectual property that gets copied across internal and public repos over time, a risk explored further in Code Repository Leaks: Protecting Your Intellectual Property.
FAQ
Does deleting a repository remove secrets from history?
Deleting the repository from its current host removes it from that host, but not from any forks, clones, mirrors, or cached copies that existed before deletion. If the repository was ever public, assume any commit that was pushed has potentially been cloned elsewhere.
Can force-pushing over a bad commit fix the exposure?
Force-pushing changes what the default branch points to, but the old commit objects can persist in forks, other branches, tags, or anyone’s existing local clone. It reduces visibility, not exposure – rotation is still required.
How far back should a history scan go?
Ideally the full history of the repository, not just a recent window. Secrets from early development or prototype phases are frequently forgotten and left unrotated far longer than recent ones, making them a common source of stale but still-valid credentials.
Treating Git history as a permanent, searchable record – rather than something that resets when a file is deleted – is the mindset shift that prevents these leaks from going unnoticed. Scan the full commit graph, rotate anything that was ever exposed, and build enforcement into the push path rather than relying on developer discipline alone.
