This is the most common credential-exposure route in software, and it is almost entirely a misunderstanding of one design decision. Git is not a file editor with undo. It is a content-addressed object store with an append-only log on top, and once content enters that store, removing it takes a deliberate operation that most people never run.
What git actually stores
When you commit a file, git hashes its contents and writes a blob object named by that hash. A tree object records which filenames point at which blobs. A commit object points at one tree, plus its parent commits. Every object is immutable, because its name is derived from its contents.
Deleting the file writes a new tree that no longer mentions it, inside a new commit. The old commit still exists, still points at the old tree, which still points at the blob holding your key. Nothing was overwritten because nothing in git can be overwritten. Recovering it takes no forensics:
git log --all --full-history -- path/to/configlists every commit that touched the file, including the one that removed it.git show <commit>:path/to/configprints the contents as of that commit.git log -p -S 'AKIA'searches history for commits where a given string was added or removed, which is how automated scanners work.
Git is useful as an audit record because you can prove what a file contained at a given commit. A committed secret is durable for the same reason. It is a hash-linked history, and it is doing exactly what it was designed to do.
The measured scale of this
A 2019 measurement study of public GitHub by researchers at North Carolina State University, presented at the Network and Distributed System Security Symposium, scanned a large sample of public repositories for high-confidence credential patterns. It found hundreds of thousands of API keys and private keys, and it found that they kept arriving daily rather than tapering off. A substantial share of what it found was still valid when the researchers re-checked later, meaning these were live credentials sitting in public.
Attackers automate against the same public event stream, and cloud providers have reported credential abuse beginning within minutes of a public push. The window between "committed by accident" and "used by someone else" is not measured in days.
Once a credential has been pushed to a remote, the question that matters is what it can still do, and how fast you can make it do nothing.
The order of operations
Rotate the credential first. Issue a new one, deploy it, then revoke the old one at the provider. This is the only step that actually ends the exposure, because it makes the leaked bytes worthless regardless of who holds them. Everything else is cleanup of a copy you can no longer account for.
Then check what it did. Pull the provider's access logs for the credential over its whole exposed lifetime, not just since you found it. The relevant period starts at the push, and the push may have been years ago.
Then purge, if the value itself is sensitive. A rotated API key is a dead string and purging it is tidiness. A customer database dump, a private key that also protects something else, or personal data committed by mistake are different, because the value stays sensitive after the credential is revoked.
What a history rewrite reaches, and what it misses
The current tool for this is git filter-repo. Git's own documentation now steers people away from filter-branch, which is slow and easy to get subtly wrong. The BFG Repo-Cleaner is a well-established alternative that trades flexibility for speed on the common case.
Rewriting produces new commit hashes for everything downstream of the change, which is why it requires a force push and coordination with everyone working on the repository. That is the visible cost. The invisible cost is the set of places the rewrite does not reach.
| Where the old object survives | What clears it |
|---|---|
| Your own local repository, via reflog and existing packfiles | Expiring the reflog and running garbage collection with pruning |
| Every clone anyone already made | Nothing you control |
| Forks on the hosting platform, which often share an object store with the source | Platform support, per their documented process |
| Cached web views of the old commit, reachable by its hash | Platform support |
| Continuous integration logs and build artifacts | Log retention settings, deleted per run |
| Mirrors, backups, package caches, local developer machines | Whatever process created them |
On the major hosting platforms, a commit that is no longer referenced by any branch can still be fetched by its full hash until it is garbage collected, and the platforms document a support process for removing cached views. Plan for that step rather than discovering it, and note that it is a request to a third party rather than something you execute.
After a purge, clone the repository fresh into a new directory and search it. If your scanner is clean on a fresh clone but you never rotated the credential, the exposure is unresolved and now harder to see.
Where secrets hide besides config files
Most tooling scans file contents at the current commit, which is the narrowest possible view. The recurring surprises are elsewhere:
- Commit messages and pull request descriptions. A pasted token in a commit message is not in any blob, so file-content scanners miss it entirely, and it is fully public.
- Test fixtures and example files. A "sample" credential is very often a real one that was working when the test was written.
- Notebooks. Output cells store whatever was printed, including an environment dump.
- CI configuration. A workflow file that echoes a variable for debugging writes it into a public build log.
- Container image layers. A secret copied in and deleted in a later layer stays in the earlier layer, which is the same failure with a different object store.
- Lockfiles and generated config. Tokens embedded in registry URLs are easy to generate and easy to overlook.
Preventing the next one
Detection tools are worth running, and it is worth being clear about what they are. gitleaks and trufflehog match known credential formats and entropy patterns, and hosted secret scanning does the same at push time, with some providers automatically notifying the credential's issuer so it can be revoked. All of these catch the shapes they know. A password in a YAML field named db_pass matches nothing in particular.
The structural changes do more:
- Keep plaintext secrets out of the working tree at all. Read them from the environment or a secrets manager at runtime, so there is nothing to accidentally stage.
- If configuration must live in the repository, commit it encrypted. Tools that encrypt values in place, leaving the structure readable, mean a leaked file is ciphertext and the exposure is the key rather than the repository.
- Make the check a gate rather than a habit. A pre-commit hook that refuses the commit is the only version of this that survives a busy week, and a server-side check backs it up when a hook was never installed.
- Scope credentials tightly and rotate them on a schedule. A key that can only read one bucket and expires in a month is a smaller incident than one that can do anything indefinitely. This is the same reasoning behind the shift in rotation guidance for user passwords, applied where rotation actually earns its cost.
One more habit is worth calling out. When a leak is found, people frequently rewrite history quietly and say nothing, because the diff looks clean afterwards and nothing appears to have happened. The credential was public for the whole window regardless, and the provider's access logs are the only thing that can say whether anyone used it. The same point applies to deleting a file from a disk: removing your copy says nothing about whose hands it passed through first.
Haven's own repositories run a secret scanner as a commit gate, so a credential is refused before it can reach a remote at all. Where a project keeps its credentials, and what happens when one gets committed anyway, is a reasonable thing to ask about any software you depend on.