Secrets in git history, explained
Deleting the commit does not delete the secret. Why git keeps every version forever, what GitHub keeps after a force push, and how to actually clean it up.
- security
- vibe coding
- engineering
Somebody notices a key in a config file. They delete the line, commit, push, and
the file is clean. git grep finds nothing. A code review would pass. The
project looks fine, and the credential is still there, fully readable, by anyone
who has ever cloned the repository and by several people who have not.
This is the most persistently misunderstood thing about git among people who use it every day, and the misunderstanding has a specific cause: the interface shows you diffs, so it is natural to assume that is what is stored.
Git stores versions, not changes
Git is a content-addressed object store. Every file you have ever committed is a
blob, named by the SHA-1 of its contents, sitting in .git/objects. A commit is
a pointer to a tree of those blobs. The diff you see in git log -p is computed
on the fly by comparing two trees.
So a commit that removes a secret does exactly one thing: it adds a new blob
without the secret, and points a new commit at it. The old blob is untouched. It
is still in .git. It is still reachable by its hash. It is in every clone,
every fork, every CI cache, and every backup made in between.
You can see it yourself. In any repository where a file was edited:
# Every version of every file, including ones that no longer exist.
git rev-list --objects --all | wc -l
# Search the full content of every commit, not the working tree.
git log -p --all -S "AKIA" --pickaxe-all
git log -S is the tool people reach for last and should reach for first: it
finds commits where the number of occurrences of a string changed, which is
exactly the shape of “somebody added a key and somebody later removed it.”
What GitHub keeps, which is more than git does
Assume for a moment that you do the rewrite correctly and force-push a clean history. On your machine, the old commits become unreachable and eventually get garbage collected. On GitHub, they do not.
Truffle Security documented this in a 2024 writeup and gave it a name: Cross Fork Object Reference, or CFOR, “when one repository fork can access sensitive data from another fork (including data from private and deleted forks).” The behavior falls out of how GitHub stores a repository and all of its forks in one shared object pool. Their summary of the consequence: “As long as one fork exists, any commit to that repository network (ie: commits on the ‘upstream’ repo or ‘downstream’ forks) will exist forever.”
Three specific cases follow from that, and each surprises people separately:
- Delete a fork, and the commits you pushed to it remain accessible through the network’s other repositories.
- Delete the upstream repository after somebody forked it, and “all of the commits from the ‘upstream’ repository still exist and are accessible via any fork.”
- Open-source a project that had a private fork, and any commit made to the private fork before that moment becomes publicly reachable.
None of this is a GitHub bug and they have not treated it as one. It is how the storage model works, and Truffle’s own conclusion after reading the documentation was that “GitHub designed repositories to work like this.”
The force-push case has been measured. A
follow-up study
scanned what the researcher calls “oops commits”, the ones erased by a force
push, by finding them in the public GitHub Archive event stream: a force push
that resets a branch shows up as a PushEvent with an empty commit array, and
the event still records the SHA that was pushed away. Scanning every such event
since 2020 turned up thousands of active secrets and $25,000 in bug bounties,
including a GitHub token with admin access across the Istio repositories.
The takeaway is not that GitHub is unsafe. It is that the two seconds between
git push and git push --force are the entire window in which the secret was
private, and that window closed before you noticed.
This lands hardest on projects with continuous sync to a repository, because the commits are made by the assistant rather than by a person deciding what to include. Lovable’s two-way GitHub sync commits every AI change, which is genuinely good for security in every respect but this one: whatever a generated file contained at the moment it was written is in the history now, and nobody reviewed the diff before it landed. The same holds once a Replit project is connected to a repository.
GitHub’s own instructions start where you would not expect
Their documentation on removing sensitive data opens with the step most people treat as cleanup rather than as the fix: “if the sensitive data you need to remove is a secret (e.g. password/token/credential), as is often the case, then as a first step you need to revoke and/or rotate that secret.”
They are equally direct about the limits of the rewrite. “You cannot remove sensitive data from other users’ clones of your repository.” And a rewrite alone does not clear GitHub’s own copies: you “can permanently remove cached views and references to the sensitive data in pull requests on GitHub by contacting us through the GitHub Support portal.”
That last one is the step that gets skipped in every tutorial. A secret quoted in a pull request diff, or reachable at a commit URL that somebody once linked, survives your rewrite until a human at GitHub processes a support request. Plan for the rewrite to take a support ticket and a few days, and plan for the rotation to take five minutes and happen first.
Doing the cleanup, in the order that works
1. Rotate the credential. At the provider, now, before anything else. This is the only step that closes the exposure; everything below is hygiene performed on a dead string. API key exposure explained covers why the intuitive order (rewrite first, rotate when you have time) is exactly backwards.
2. Find out what is actually in there. Scan the history rather than the checkout, and scan every ref rather than the default branch:
# Fetch everything the remote will give you, including other branches.
git clone --mirror https://github.com/you/your-repo.git repo.git
cd repo.git
# An ordinary clone never fetches pull-request refs, and they often hold
# commits that were merged to no branch. Belt and braces after --mirror.
git fetch origin '+refs/pull/*/head:refs/pull/*'
# Then scan commits, not files. `gitleaks git` walks `git log -p`; its sibling
# `gitleaks dir` is the one that reads a directory and ignores history.
# On gitleaks before 8.19 these were spelled `detect` and `detect --no-git`.
gitleaks git . --report-format json --report-path leaks.json
# TruffleHog's git mode walks the same history with a different detector set.
trufflehog git file://. --no-verification
Run both. They have different detector sets and the overlap is the point: a credential one of them has no rule for is the credential you most need told about.
3. Rewrite, with the right tool.
git filter-repo is what the git
project itself recommends now; git filter-branch is deprecated and slow enough
to be unusable on a real repository. Put the offending strings in a file, one per
line, and let it replace them everywhere:
# secrets.txt, one literal per line
pip install git-filter-repo
git filter-repo --replace-text secrets.txt
git push --force --all
git push --force --tags
4. Then handle the parts a rewrite cannot reach. Tell collaborators to re-clone rather than pull, since a merge would reintroduce the old objects. Delete stale forks you control. Open the GitHub Support request for the cached views and pull-request references. And accept the part you cannot fix: the clone on somebody’s old laptop stays as it is, which is the real reason step 1 is step 1.
Stopping the next one at the commit
Detection after the fact is a fallback with a poor success rate, because the window between commit and push is where the whole problem is created and it is usually a few seconds long. Two controls close most of it.
A pre-commit hook that runs a secrets scanner over the staged diff costs about a second per commit:
# .git/hooks/pre-commit (or via the pre-commit framework)
gitleaks git --pre-commit --staged --no-banner || {
echo "gitleaks found a secret in the staged changes"
exit 1
}
--staged is the flag that matters here: it looks at what you are about to
commit rather than at what you already did, which is the difference between a
warning and a prevention. On gitleaks before 8.19 the same thing is spelled
gitleaks protect --staged.
The second control is server side and free on GitHub: turn on push protection, which rejects a push containing a recognized provider credential. It only knows patterns from partnered providers, so it will not catch your own HMAC signing secret, but it catches the AWS, Stripe and GitHub tokens that make up most real incidents.
What we scan, and what we do not
This is the section where a scanner vendor is tempted to imply more coverage than it has, so here is the exact behavior.
Our pipeline clones your repository with git clone --depth 1, a shallow clone
that fetches the tip of one branch. Gitleaks then runs with --no-git over that
checkout, and TruffleHog runs in filesystem mode over the same directory.
Both scan the working tree, not the history. The history was never fetched,
so there is nothing on disk for them to walk.
That is a deliberate trade and not an oversight. A shallow clone is what keeps a
scan of a large repository down to seconds instead of minutes, and it is what
lets us not retain your source. It is also a real gap, and it is the reason the
git clone --mirror block above exists in this post rather than a line telling
you we have it covered. A secret introduced and removed before your current tip
is invisible to our secrets layer today. Run the mirror scan yourself, once, on
each repository that predates your .gitignore.
What our layers do cover is the copy that is still live: a credential in the current tree is hardcoded credentials, a credential in a file your server publishes is .env file exposure, and a credential your build tool inlined into the JavaScript is a client-side env prefix. Those three plus this one are the same secret in four places, which is why the response is always rotation rather than removal.
The asymmetry that decides your response
Git was designed so that history cannot be quietly altered. That property is why you can trust a commit log, and it is the same property that makes this class permanent: the guarantee does not distinguish between a colleague rewriting the record and you removing your own mistake.
So the correct mental model is not “I need to clean this up.” It is
the one from API key exposure: the moment a
secret was pushed, it was published. Rotation is the only action that ends the
exposure, and every minute spent researching filter-repo syntax before
rotating is a minute the live credential is answering to whoever found it first.