Two uncomfortable truths before any commands. First: the moment a secret reached a remote — or a teammate's clone, a CI log, a fork — treat it as compromised. Scrubbing git history does not un-leak it. Second: scrubbing is still worth doing, as hygiene and diligence. Do both, in the order below.
TL;DR — rotate at the source, then:
pip install git-filter-repo
git clone git@github.com:you/repo.git && cd repo
git filter-repo --replace-text secrets.txt
git remote add origin git@github.com:you/repo.git
git push --force --all && git push --force --tags
Then file the GitHub Support request described further down. That last step is the one almost every guide skips — and the one that actually removes the old commits from GitHub's servers.
Step 0: rotate the secret. Now.
Bots scrape public repos for keys within seconds of a push. Whether yours was scraped is unknowable, so assume it was: revoke or rotate the credential at its source — the AWS console, the Stripe dashboard, your CI provider, wherever it was issued.
Here's the part that should lower your heart rate: rotation alone is often sufficient. Once the old value is dead, every lingering copy of it — in history, in forks, in some scraper's database — is inert. The history scrub you're about to do is about not advertising past mistakes and closing out secret-scanning alerts, not about making the secret safe. It's already safe, because you killed it.
Step 1: scrub the history with git filter-repo
git filter-repo is the current standard for history rewriting. Don't reach for git filter-branch — it's deprecated by git itself, orders of magnitude slower, and famous for footguns. (The BFG Repo-Cleaner is a fine alternative if you'd rather have a single jar file; it's faster on enormous repos but less surgical.)
Work in a fresh clone — filter-repo refuses to run in a repo with uncommitted state or leftover rewrite artifacts unless you pass --force, and that refusal has saved a lot of people from themselves:
git clone git@github.com:you/repo.git && cd repo
Option A — the secret lives in a file that should never have existed. Remove the file from every commit:
git filter-repo --path path/to/.env --invert-paths
If it was committed under different directories over time, match on the file name wherever it lived:
git filter-repo --path .env --use-base-name --invert-paths
Option B — the secret string is sprinkled across files. Replace the string everywhere it appears in any revision of any file. Put the values in a plain text file, one per line:
AKIAIOSFODNN7EXAMPLE
xoxb-1234-5678-secret-token==>REDACTED
Lines without ==> are replaced with ***REMOVED***; lines with it use your replacement. Then:
git filter-repo --replace-text secrets.txt
When filter-repo finishes it has rewritten every branch and tag in one pass — and deliberately removed the origin remote so you can't half-push a rewrite by accident. Re-add it and force-push everything:
git remote add origin git@github.com:you/repo.git
git push --force --all
git push --force --tags
Step 2: everyone re-clones
Every existing clone still holds the old history, and pulling into one can drag the scrubbed commits straight back in. Have every collaborator delete their clone and re-clone. Forks are worse: they keep the secret in their own copy of history, and you cannot scrub forks you don't control — another reason step 0 is the real fix and everything else is hygiene.
Step 3: the GitHub part everyone skips
Force-pushing does not make the old commits vanish from GitHub. Three things remain true:
Old SHAs still load. https://github.com/you/repo/commit/<old-sha> will happily serve the commit — secret included — until GitHub's garbage collector removes the unreachable object. There is no client-side way to force that collection: no API, no flag, no trick. It can take a very long time, and GitHub publishes no schedule.
Pull-request refs survive the force-push. Any commit that ever appeared in a PR lives on under the hidden refs/pull/* namespace, which your push never touches. Those commits stay reachable until GitHub themselves purge them.
Views get cached. GitHub caches rendered commit and compare pages, so even collected objects can linger in cached responses.
The only path through all three is GitHub Support — their own sensitive-data docs say so explicitly. Filing the request takes ten minutes:
- Go to support.github.com and open a ticket (Account and security, or just ask the virtual assistant for "remove sensitive data").
- Give them the repository name, the affected commit SHAs, and the pull request numbers that contained the secret.
- State that you have already force-pushed the rewritten history, and ask them explicitly to purge the pull-request refs and clear the cached views of the affected commits.
- When they confirm, verify: the old commit URLs should now 404.
Nobody else walks you through this step. It's the difference between "we force-pushed and hoped" and "the secret is actually gone from GitHub's servers."
The nuclear option
If the repo is young and the leak is bad enough, delete the repository and recreate it. Total and instant — but you lose stars, watchers, issues, pull requests, releases, and fork relationships. For an established repo that's almost never the right trade; for a two-week-old repo that leaked a production database password, it might be.
Back up before the scary thing
Backup seals an encrypted, restorable copy of your full git history — locked with a key only you hold. Take one before you rewrite history, and a botched scrub becomes a minor inconvenience instead of a bad week.
Open Backup →What "done" actually looks like
Be precise with the language, especially in incident reports. The secret was never "removed from git" — what happened is: the credential was rotated, the history was scrubbed, PR refs and cached views were purged by GitHub Support, and every clone was replaced. Each item on that list is verifiable. Check them all off before you call it done.
Common questions
If I rotate the secret, do I still need to rewrite history?
Strictly speaking, no — a dead credential can't be abused. But most teams scrub anyway: secret-scanning alerts stay open, compliance auditors ask questions, and a repo full of old keys teaches every future reader that committing secrets is normal here.
I force-pushed, but the old commit URL still loads. Did it fail?
No — your rewrite worked. GitHub keeps unreachable commits accessible by SHA until its garbage collector runs, and there's no way to trigger that from your side. Commits referenced by pull requests survive even longer. File the support request from step 3 to get them purged.
Does committing a deletion of the file help?
Not even slightly. git rm .env && git commit removes the file from the latest commit while every earlier commit still contains it — and history is exactly where people go looking. Only a rewrite plus a force-push changes what's in the repo's past.
What about forks?
Forks have their own independent copy of the history, including your secret, and you can't rewrite repos you don't own. GitHub Support can help with forks in sensitive-data cases, but the real protection is rotation — a revoked key in a fork is just a string.
Will the GitHub secret scanning alert go away after I scrub?
The alert stays open until you resolve it in the Security tab, even after the history is clean. Resolving it after rotating and scrubbing is correct; resolving it instead of rotating is not.