Skip to content

Remove Secrets from Git History Safely

Lesson 3 of 8Advanced15 min readGit Security & DevSecOps · Secret SecurityVerified: git 2.43.0 and git-filter-repo on Ubuntu 24.04, September 2026

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.

Credential exposure response

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.

1. Secret discoveredAlert, scan, or someone noticing2. Revoke / rotateFirst. Not negotiable.3. Determine exposureWhat could it reach; was it used?4. Remove from current filesStop re-committing it5. Rewrite historyOptional. Disruptive.6. Coordinate force updatesEveryone with a clone7. Clean clones and forksThe rewrite does not reach them8. VerifyConfirm the old credential fails9. MonitorWatch for continued use attempts

Step 5 is dashed because it is genuinely optional. Steps 2 and 8 never are.

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.

Terminal window
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.

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.

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:

Terminal window
{/* 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.

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.

Two tools are current, and they suit different jobs.

git filter-repoBFG Repo-Cleaner
Maintained byThe Git community; recommended over the deprecated filter-branchThe open-source community
RequiresPythonA Java runtime
StrengthPrecise and general — paths, content, authors, refsFast and simple for the common cases
Typical use“Remove exactly this, everywhere”“Strip these files or strings from history”
Safety behaviourRemoves the origin remote after rewritingExpects 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.

The commands below were run against a small test repository. The output is real.

  1. Back up first. A rewrite is not reversible in place.

    Terminal window
    git clone --mirror git@github.com:OWNER/REPO.git repo-backup.git

    A mirror clone captures every ref, which is what you want if this goes wrong.

  2. Close or merge open pull requests. They reference commits that are about to stop existing. Doing this first avoids a second cleanup.

  3. Work on a fresh clone. filter-repo expects one, and it means your working copy is not part of the problem.

    Terminal window
    git clone git@github.com:OWNER/REPO.git repo-rewrite
    cd repo-rewrite
  4. Write the replacement rules. One rule per line, LITERAL==>REPLACEMENT:

    replacements.txt
    EXAMPLE_SECRET_abc123==>REMOVED
  5. Run the rewrite.

    Terminal window
    git filter-repo --replace-text replacements.txt

    Real output from the test repository:

    Parsed 3 commits
    New history written in 0.05 seconds; now repacking/cleaning...
    Repacking your repo and cleaning out old unneeded objects
    HEAD is now at f190146 Stop tracking .env
    Completely finished after 0.16 seconds.
  6. 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 for REMOVED returned 2 — the two commits that had contained the value.

  7. Push the rewritten history. filter-repo removes the origin remote 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.git
    git push --force --all
    git push --force --tags

Where the file should never have existed at all — a .env, a key file — remove the path rather than the content:

Terminal window
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.

For a large repository, or when using BFG, the conventional route is a bare mirror rather than a working clone.

Terminal window
git clone --mirror git@github.com:OWNER/REPO.git repo.git
cd repo.git

A 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:

Terminal window
java -jar bfg.jar --replace-text replacements.txt repo.git
cd repo.git
git reflog expire --expire=now --all && git gc --prune=now --aggressive
git push --force

The 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.

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.

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:

Terminal window
{/* Fetch the rewritten history and discard the local branch entirely */}
git fetch origin
git reset --hard origin/main

What 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:

Terminal window
git format-patch origin/main --stdout > my-work.patch
{/* then reset hard, then re-apply */}
git am my-work.patch

The 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.

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.

Do not skip this. Verification is what distinguishes a completed incident from a believed one.

  1. Confirm the old credential fails. Use it. Expect a 401 or 403. This is the only evidence that step 1 worked.

  2. Confirm the value is gone from the rewritten repository — a fresh clone, then git log --all -p -S 'VALUE' returning nothing.

  3. Confirm collaborators have reset. A single person force-pushing their old branch undoes the whole exercise.

  4. Check the secret scanning alert. It should now be resolvable as revoked.

  5. Check provider logs for use of the credential between exposure and revocation. This is the question the write-up needs to answer.

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.

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”.

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:

Terminal window
{/* 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?”

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.

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.

  • 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 commit
  • git filter-repo is the current default; BFG is the simpler alternative; filter-branch should not be used
  • --replace-text substitutes content; --invert-paths --path X removes a file entirely
  • filter-repo deliberately removes the origin remote, 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

Use a disposable repository. Nothing here should touch anything real.

  1. Create a repository, commit a file containing API_KEY=EXAMPLE_SECRET_abc123, then two more commits, one of which deletes the file.

  2. Run git log --all -p -S 'EXAMPLE_SECRET_abc123'. Predict: how many commits does it name, given the file has been deleted?

  3. Note the current commit SHAs with git log --oneline.

  4. Run git filter-repo --replace-text with a rule replacing the value. Predict: how many of the three SHAs change?

  5. Verify with the grep -c command from step 6 above.

  6. Run git remote -v. Predict: is origin still configured?

  7. 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.

  8. Delete both copies and the repository.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A live API key was committed an hour ago. What is the first action?
Show answer

Rotate the key — the rewrite is cleanup and does nothing to the credential — The lesson calls rewriting before rotating the single most consequential error in the pillar. Anyone who fetched the commit has the key; only rotation invalidates it.

You delete the file containing the secret in a new commit and push. Is the secret gone?
Show answer

No — the earlier commit still contains it, in every clone — A commit's hash is its content, so a later deletion changes nothing about the earlier commit. The secret is in history until history is rewritten — and in every clone regardless.

After `git filter-repo`, the `origin` remote is missing. Why?
Show answer

Deliberately — to stop you pushing rewritten history to the shared remote without thinking — `filter-repo` removes the remote on purpose. Pushing a rewritten history is a coordination event for everyone with a clone, so the tool forces you to re-add the remote as a conscious step.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The secrets management checklist and least-privilege token guide are in the Professional Toolkit.