A credential has been committed and pushed. This page is the procedure.
Before any of it, one sentence that determines whether the rest is useful:
Removing a secret from Git history does not make the exposed credential safe again.
Rewriting history is slow, disruptive and requires coordinating everyone with a clone. Rotating the credential is usually one API call. People start with the rewrite because it feels like the fix, and every hour spent on it is an hour the credential still works.
The sequence
Section titled “The sequence”A vertical sequence: secret discovered, revoke or rotate, determine exposure, remove from current files, rewrite history if required, coordinate force updates, clean clones and forks, verify, monitor.
Step 5 is dashed because it is genuinely optional. Steps 2 and 8 never are.
Why Git keeps it
Section titled “Why Git keeps it”A Git commit is immutable. Its identity is the hash of its content — tree, parents, author, message. Change any byte and you have a different commit with a different hash, not a modified one.
So deleting a file in a new commit does not remove it from history. The old commit still exists, still
contains the file, and is still reachable from every branch and tag descended from it. Anyone with a
clone has it. git log -p shows it. So does secret scanning.
git log --all -p -S 'EXAMPLE_SECRET_VALUE'What it doesSearches every commit reachable from any ref for a string, printing the commits and diffs that changed its number of occurrences.
Why we run itIt answers the only question that matters at this stage: is the value still in history, and where did it enter?
Expected resultCommit headers and diff hunks containing the value, or no output if it is genuinely gone.
The -S form finds commits that changed the number of occurrences of the string, so it surfaces
both where the value was added and where it was removed. That is usually what you want when scoping
the problem.
Removing it requires rewriting every commit from the first one containing the secret onwards — producing new commits with new hashes, and leaving the old ones unreachable.
Step 1: rotate
Section titled “Step 1: rotate”Covered in full in Rotating Exposed Credentials. The short version:
Revoke if nothing needs it any more. Rotate if something does — create the replacement, switch, verify, then revoke the old one.
Do this now, before reading further. The rest of the page will still be here.
Step 2: determine exposure
Section titled “Step 2: determine exposure”Two separate questions, both worth answering before deciding how much of the rest to do.
What could the credential reach? Not what it was used for — what it was permitted to do. A “read-only analytics key” that was actually created with broad scope is a different incident from the one you think you are having.
Was it used? Most providers expose usage logs or a last-used timestamp. For a public repository, assume yes until you have checked; scrapers act within minutes.
The answers determine everything downstream. A key that was never valid outside a sandbox and has now been revoked probably does not justify rewriting a shared repository’s history. A signing key whose exposure makes every artefact you published this year questionable is a different conversation.
Find every secret, not just the one you were told about
Section titled “Find every secret, not just the one you were told about”A credential rarely arrives alone. The commit that added one .env file usually added several values,
and the same file has often been re-added, moved and copied over the repository’s life.
Before deciding scope, scan the whole history rather than the alert:
{/* Scan every commit, redacting matches in the output */}gitleaks git --redact -v .--redact matters. Without it, the report prints the secrets, and the report then becomes a file
containing every credential in your repository — which people paste into tickets.
Two things this typically reveals:
More values than the alert covered. GitHub’s scanning covers supported patterns; a broader scanner with generic rules will find things it does not, including internal formats.
A different first commit. The alert points at where the scanner noticed. -S and a full history
scan point at where the value entered, which is where the rewrite has to start.
GitLeaks and TruffleHog cover this in depth. For the purpose of this page, the point is that scoping is a discovery step, not a transcription of the alert.
Step 3: is a rewrite actually warranted?
Section titled “Step 3: is a rewrite actually warranted?”Rewriting shared history is genuinely disruptive: every clone diverges, open pull requests break, and every collaborator has work to do. It is worth it sometimes and not always.
Rewrite when:
- The value is intrinsically sensitive and cannot be rotated — a private key, personal data, customer information, something under a legal obligation
- The repository is public and the content itself is the harm, not just the access
- A compliance requirement names removal specifically
Usually do not rewrite when:
- The credential is revoked and dead, and the string is now just a dead string
- The repository is private, with a small and known set of clones
- The disruption cost clearly exceeds the residual risk
The honest framing: once the credential is revoked, a rewrite removes a historical artefact, not a risk. That can still be worth doing. It is a different decision from remediation, and running the two together is how the ordering mistake happens.
Step 4: choose a tool
Section titled “Step 4: choose a tool”Two tools are current, and they suit different jobs.
git filter-repo | BFG Repo-Cleaner | |
|---|---|---|
| Maintained by | The Git community; recommended over the deprecated filter-branch | The open-source community |
| Requires | Python | A Java runtime |
| Strength | Precise and general — paths, content, authors, refs | Fast and simple for the common cases |
| Typical use | “Remove exactly this, everywhere” | “Strip these files or strings from history” |
| Safety behaviour | Removes the origin remote after rewriting | Expects a bare mirror clone |
GitHub’s own guidance names both. git filter-repo is the better default for a secret removal,
because --replace-text handles the case where the value appears inside files you want to keep.
A third option worth knowing about and rarely reaching for: for a repository whose history is short
and entirely yours, an interactive rebase can edit the
offending commits directly. That is fine for three commits on an unshared branch and becomes
unmanageable quickly, because you are hand-editing every commit from the first affected one onwards —
which is exactly the work filter-repo automates.
git filter-branch still ships with Git and should not be used. It is slow, its edge cases are
numerous enough that Git’s own documentation warns against it, and both alternatives exist precisely
to replace it.
Step 5: rewrite
Section titled “Step 5: rewrite”The commands below were run against a small test repository. The output is real.
-
Back up first. A rewrite is not reversible in place.
Terminal window git clone --mirror git@github.com:OWNER/REPO.git repo-backup.gitA mirror clone captures every ref, which is what you want if this goes wrong.
-
Close or merge open pull requests. They reference commits that are about to stop existing. Doing this first avoids a second cleanup.
-
Work on a fresh clone.
filter-repoexpects one, and it means your working copy is not part of the problem.Terminal window git clone git@github.com:OWNER/REPO.git repo-rewritecd repo-rewrite -
Write the replacement rules. One rule per line,
LITERAL==>REPLACEMENT:replacements.txt EXAMPLE_SECRET_abc123==>REMOVED -
Run the rewrite.
Terminal window git filter-repo --replace-text replacements.txtReal output from the test repository:
Parsed 3 commitsNew history written in 0.05 seconds; now repacking/cleaning...Repacking your repo and cleaning out old unneeded objectsHEAD is now at f190146 Stop tracking .envCompletely finished after 0.16 seconds. -
Verify locally, before pushing anything:
Terminal window git log --all -p | grep -c 'EXAMPLE_SECRET_abc123'In the test run this returned
0, and searching forREMOVEDreturned2— the two commits that had contained the value. -
Push the rewritten history.
filter-reporemoves theoriginremote deliberately, so you must add it back — a safety measure that forces you to confirm where you are pushing.Terminal window git remote add origin git@github.com:OWNER/REPO.gitgit push --force --allgit push --force --tags
Removing a file entirely
Section titled “Removing a file entirely”Where the file should never have existed at all — a .env, a key file — remove the path rather than
the content:
git filter-repo --invert-paths --path .env--invert-paths means “keep everything except these paths”. Without it, the command keeps only
those paths, which is a very effective way to delete your repository.
The mirror-clone approach
Section titled “The mirror-clone approach”For a large repository, or when using BFG, the conventional route is a bare mirror rather than a working clone.
git clone --mirror git@github.com:OWNER/REPO.git repo.gitcd repo.gitA mirror clone has no working tree and contains every ref — branches, tags and remote refs — which is exactly what a history rewrite needs to touch. BFG expects this form:
java -jar bfg.jar --replace-text replacements.txt repo.gitcd repo.gitgit reflog expire --expire=now --all && git gc --prune=now --aggressivegit push --forceThe reflog expire and gc step is doing something specific and worth understanding: the rewrite
makes the old commits unreachable, but unreachable objects are not deleted immediately. The reflog
still references them, and Git’s garbage collection is deliberately conservative. Until that step
runs, the old objects — with the secret in them — are still in the object database and still
recoverable locally.
git filter-repo performs the equivalent cleanup itself, which is one of the reasons it is the
easier default.
What happens on GitHub’s side
Section titled “What happens on GitHub’s side”Pushing rewritten history does not delete the old objects from GitHub immediately either. They become unreachable, and unreachable objects can remain retrievable by SHA for a period — which means a value somebody has the commit SHA for may still be fetchable after your rewrite looks complete.
This is another reason rotation is the remediation. For a genuine exposure of something that cannot be rotated, contacting GitHub Support to request garbage collection of the affected objects is part of the procedure rather than an optional extra.
Step 6: everyone else
Section titled “Step 6: everyone else”The rewrite changed every commit hash from the first affected commit onwards. In the test above, all three commits got new SHAs. Every clone is now based on history that no longer exists upstream.
What each person must do:
{/* Fetch the rewritten history and discard the local branch entirely */}git fetch origingit reset --hard origin/mainWhat they must not do is merge, rebase onto their old work, or force-push their own copy. Any of those reintroduces the old commits, and with them the secret.
For anyone with work in progress, export it as a patch first:
git format-patch origin/main --stdout > my-work.patch{/* then reset hard, then re-apply */}git am my-work.patchThe blunter and often better instruction for a large team: delete the clone and clone again. It is faster than explaining the alternative, and it has no failure mode.
Step 7: forks and GitHub-side copies
Section titled “Step 7: forks and GitHub-side copies”The parts a rewrite cannot reach.
Forks are separate repositories. They keep the old commits. If the repository is public and has forks, the value is still reachable through them. You cannot fix this yourself — GitHub Support can help with cached views and fork network cleanup for a genuine exposure, and that is worth doing for material disclosures.
Pull request refs persist. GitHub retains refs/pull/*, and commits referenced there can remain
accessible after a rewrite. Closing pull requests beforehand reduces this; contacting Support handles
the rest.
Anything that mirrored or cached it. Internal mirrors, CI caches, artefact stores, search indexes, and anyone’s laptop.
This is the concrete reason the ordering matters. Rotation reaches all of these at once, because it invalidates the credential rather than the copies. Rewriting reaches none of them.
Step 8: verify
Section titled “Step 8: verify”Do not skip this. Verification is what distinguishes a completed incident from a believed one.
-
Confirm the old credential fails. Use it. Expect a
401or403. This is the only evidence that step 1 worked. -
Confirm the value is gone from the rewritten repository — a fresh clone, then
git log --all -p -S 'VALUE'returning nothing. -
Confirm collaborators have reset. A single person force-pushing their old branch undoes the whole exercise.
-
Check the secret scanning alert. It should now be resolvable as revoked.
-
Check provider logs for use of the credential between exposure and revocation. This is the question the write-up needs to answer.
Communicating during the incident
Section titled “Communicating during the incident”The technical steps are the easy part. The rewrite fails in practice when someone did not get the message, and the message has three distinct audiences.
Everyone with a clone needs one instruction, sent before the force push, and it should be the blunt one: do not push; after the announcement, delete your clone and re-clone; if you have uncommitted work, tell me first. Anything more nuanced gets partially followed.
Whoever owns the credential’s system needs to know it was rotated and why, because they are the people who will see the failed authentication attempts and the support ticket from whatever broke.
Anyone downstream, if the exposed value was something they relied on — a signing key, a shared integration token. This is the audience most often skipped, and the one for whom your incident is their unexplained outage.
One thing worth stating explicitly in the announcement: the credential is already rotated. Without that sentence, a well-meaning colleague will start a parallel effort, and two people rewriting the same history is a worse day than the original leak.
Step 9: monitor
Section titled “Step 9: monitor”For a period afterwards, watch for attempts to use the revoked credential. Continued attempts tell you the value is in somebody’s list, which changes your assessment of how widely it was distributed and how carefully to watch adjacent systems.
Then write it down. Not for compliance: for the next person. What leaked, how it got in, how it was found, what was done, and what would have prevented it. That last field is the one that produces change, and the most common honest answer is “push protection was not enabled on this repository”.
The threat model
Section titled “The threat model”Threat. A credential that has been committed to a repository is read by somebody other than its owner, from any of the many copies Git creates by design.
Attack surface. Deliberately broad, because this is the point people underestimate: the repository, every clone, every fork, every CI runner that fetched it, every mirror, every backup, GitHub’s pull request refs, and any archive or export. Git’s distribution model is a feature that becomes an attack surface the moment the content is sensitive.
Impact. Whatever the credential reaches. Note that the impact does not decrease over time on its own — an unrotated credential in a five-year-old commit is exactly as valid today as it was then.
Control. Rotation, which invalidates the credential across every copy simultaneously. History rewriting is a secondary control that reduces future discoverability in one copy.
Verification. Use the credential and confirm it fails. Then clone fresh and search history. Both, in that order.
The asymmetry between control and cleanup is the whole lesson: one action reaches every copy, the other reaches one.
When history was already rewritten by something else
Section titled “When history was already rewritten by something else”Two situations complicate the search, and both are common.
Squash merging. If the repository squash-merges pull requests, the individual branch commits never
land on the default branch. A secret committed on a feature branch and removed before merge may not be
in main’s history at all — but it is in the pull request refs GitHub retains, and it was in every
clone of that branch.
The practical consequence: git log --all -p -S on a fresh clone of main can return nothing while
the value is still reachable through the pull request. Check the pull request, not only the branch.
A previous rebase or amend. If the secret was added and then removed by an amend before pushing, it may exist only in the local reflog — which means it is in one person’s clone and nowhere else. That is the best possible version of this incident, and it is worth confirming rather than assuming:
{/* Search unreachable objects too, not only what refs point at */}git log --all --reflog -p -S 'EXAMPLE_SECRET_VALUE'Adding --reflog widens the search to commits that are no longer reachable from any branch. On your
own machine that is the honest answer to “is it really gone?”
Common mistakes
Section titled “Common mistakes”Rewriting before rotating. The single most consequential error in this pillar — the expensive half done first while the credential stays live.
Believing the rewrite fixed it. It removed a string from your copy. The credential’s validity is an entirely separate property.
Deleting the file in a new commit and stopping. The old commit still contains it, in every clone.
Not backing up first. A rewrite is not reversible in place, and a mirror clone costs seconds.
Force-pushing without telling anyone. Somebody force-pushes their old branch back and the secret returns.
Forgetting tags. git push --force --all does not push tags, and tags reference commits too.
Using git filter-branch. Slow, error-prone, and superseded by both alternatives.
Running --path without --invert-paths. That keeps only that path and discards the rest of the
repository.
Ignoring forks. They are separate repositories holding the old history, untouched by the rewrite.
Mental model
Section titled “Mental model”Git history is append-only in practice: you cannot edit a commit, only replace it and everything after it. So “removing” a secret means constructing a parallel history and persuading everyone to adopt it — which is why it is expensive, and why it is not the remediation.
What you learned
Section titled “What you learned”- Rotation comes first; a rewrite is cleanup and does nothing to the credential
- A commit’s hash is its content, so deleting a file later changes nothing about the earlier commit
git log --all -p -S 'VALUE'scopes the problem across every reachable commitgit filter-repois the current default; BFG is the simpler alternative;filter-branchshould not be used--replace-textsubstitutes content;--invert-paths --path Xremoves a file entirelyfilter-repodeliberately removes theoriginremote, so you must re-add it before pushing- Rewriting changes every commit hash from the first affected commit onwards
- Collaborators must reset hard or re-clone, never merge or force-push their old branch
- Forks, pull request refs and existing clones are outside the rewrite’s reach
- Verification means using the old credential and confirming it fails
Exercise
Section titled “Exercise”Use a disposable repository. Nothing here should touch anything real.
-
Create a repository, commit a file containing
API_KEY=EXAMPLE_SECRET_abc123, then two more commits, one of which deletes the file. -
Run
git log --all -p -S 'EXAMPLE_SECRET_abc123'. Predict: how many commits does it name, given the file has been deleted? -
Note the current commit SHAs with
git log --oneline. -
Run
git filter-repo --replace-textwith a rule replacing the value. Predict: how many of the three SHAs change? -
Verify with the
grep -ccommand from step 6 above. -
Run
git remote -v. Predict: isoriginstill configured? -
Clone the repository again into a second directory before pushing. Predict: does the second clone contain the secret? This is the fork problem in miniature.
-
Delete both copies and the repository.
Related lessons
Section titled “Related lessons”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.
The secrets management checklist and least-privilege token guide are in the Professional Toolkit.