Deleting a committed secret in a new commit leaves it in every earlier commit, every clone and every fork, so the credential stays readable to anyone with history access. This guide covers the one task of getting a secret out of Git history properly: revoking it first, rewriting history with git-filter-repo, force pushing, getting the hosting platform to purge what a push cannot reach, cleaning colleagues' clones, and proving the data is gone. It is written for platform engineers, repository owners and incident responders who have already confirmed a leak. The steps follow the git-filter-repo manual and the GitHub and GitLab documentation as of November 2026. Platforms are listed alphabetically; the order is not a ranking.
Key takeaways
- Rotation comes before rewriting. The git-filter-repo manual and GitHub's documentation both say to revoke or rotate a leaked credential first; once it is dead, a rewrite may not be needed at all.
- Rewrite only when rotation cannot fix it. Keys that protect archived data, personal data and secrets you cannot rotate are the real cases for a history rewrite.
- A force push is not the end. Pull request and merge request refs, cached views, forks and LFS objects keep old data until the platform purges them.
- Every old clone can recontaminate. One
git pullandgit pushfrom a pre-cleanup clone pushes the secret back, so freeze work and get clones deleted or cleaned. - Prove it with the first changed commit.
git cat-file -ton each first changed commit must return a fatal error in every copy you clean.
When you need this
Most leaked credentials do not need a history rewrite. A revoked token is useless however many copies of it exist. The decision is about what rotation cannot fix.
| Situation | Rotate or revoke | Rewrite history |
|---|---|---|
| API key, token or password that can be revoked | Yes, immediately | Optional; do it if the repository is public or widely cloned and you want to stop repeat scanner alerts |
| Private key for signing or TLS that can be reissued | Yes, reissue and revoke the old certificate or key | Optional, as above |
| Decryption key for data that stays encrypted with it (backups, archives) | Re-encrypt where possible | Yes: the old key still opens old data |
| Personal data, customer data or regulated content | Not applicable | Yes, and assess notification duties |
| Credential owned by a third party you cannot reach quickly | Request revocation in parallel | Yes, to shorten exposure while you wait |
GitHub states that its Support team helps purge sensitive data only when the risk cannot be mitigated by rotating credentials, which is another reason to rotate first. For triage of the finding itself, see the Developer Secret Scanning Triage. For a token on GitHub specifically, see How to revoke a leaked GitHub personal access token and check what it touched.
Before you start
- Rotate and record: revoke or rotate the credential, record the time, and check the provider's logs for use between commit time and revocation. See the Secrets Rotation Runbook Template.
- Freeze the repository: tell everyone to stop pushing. The git-filter-repo manual warns that work pushed during cleanup will either be discarded or force you to start again. On GitLab, pushes during blob removal can fail the job with a "source repository checksum altered" error, and GitLab suggests archiving the project to make it read-only (archiving also removes the fork relationship).
- Close or merge open pull and merge requests: GitHub recommends this because new commit hashes change their diffs and can invalidate review comments; GitLab notes open merge requests may need a manual rebase.
- Install git-filter-repo 2.47 or later: the
--sensitive-data-removalflag (short form--sdr) needs it. - Work in a fresh clone: with
--sensitive-data-removalthe tool fetches all refs, which can discard un-pushed local work. A fresh clone avoids that.--no-fetchkeeps local work but risks leaving the secret in refs you did not fetch. - List every path and string: find every file path the secret ever lived at, including renames, and every literal value. A secret in three files and two branches needs all of them in the rewrite.
- Check the signing impact: rewriting changes commit and tag hashes, so existing signatures will not verify against the rewritten history; check how your git-filter-repo version handles signatures. If signed history matters to you, plan to re-sign tags and tell auditors. See the Git Repository Security Guide.
Step-by-step
1. Rewrite the repository locally
- Clone fresh and change into the clone.
- Remove whole files when no version of the file should survive:
git-filter-repo --sensitive-data-removal --invert-paths --path path/to/file. Use the full path from the repository root, and add a--pathfor every earlier location if the file was moved or renamed. - Replace strings when the file should stay: write the secret values to an expressions file outside the repository, one per line, and run
git-filter-repo --sensitive-data-removal --replace-text ../expressions.txt. Values are literal by default,regex:andglob:prefixes are supported, and each match becomes***REMOVED***unless a line ends with==>and your own replacement. Replacement applies to non-binary files only. Delete the expressions file afterwards: it is now a list of secrets. - Note the output: keep the lines under
NOTE: First Changed Commit(s)and anyNOTE: There were LFS Objects Orphaned by this rewritemessage with its file. You need both later. - Inspect before pushing: run
git log --all --name-status -- path/to/fileandgit log -S"secret-fragment" --all -p --. Search with a distinctive fragment so the full value does not land in shell history. If either finds anything, rewrite again. - Count affected pull requests on GitHub with
grep -c '^refs/pull/.*/head$' .git/filter-repo/changed-refs. If the number is far higher than expected, throw the clone away and rethink: GitHub notes that once you push, the rewrite cannot be undone.
2. Push the rewritten history
- Force push every ref:
git push --force --mirror origin. This overwrites all branches and tags on the server, discarding anything pushed since you started. - Expect some refs to be rejected: forges keep review refs such as
refs/pull/*andrefs/merge-requests/*read-only. Those are handled by the platform in the next stage. - Handle branch protection: if other refs fail, protection rules are usually the cause. Lift them for the push window only, push again, and restore them straight away. Log who changed them and when.
3. Purge what the push cannot reach
GitHub. Open a request through the GitHub Support portal with the owner and repository name, the number of affected pull requests, the first changed commits, and the orphaned LFS file if there was one. GitHub describes Support then dereferencing or deleting affected pull requests, running server-side garbage collection, removing cached views and purging orphaned LFS objects. Its documentation says this happens only when all non-pull-request refs are clean and no fork still holds the data. Forks are separate: contact fork owners yourself, as GitHub cannot share their details. See the GitHub and GitHub Actions Security Guide.
GitLab. GitLab provides two built-in options under Settings > Repository > Repository maintenance, both needing the Owner role and both irreversible. Remove blobs takes blob IDs (find them with git ls-tree -r -t --long --full-name <branch>, third column). Redact text takes exact strings, with no regex or glob, and replaces them with ***REMOVED***. After either, wait for the job (GitLab says at least five minutes without an email), then under Settings > General > Advanced select Run housekeeping, wait at least 30 minutes, and select Prune unreachable objects. GitLab's documentation says its own git filter-repo cleanup path is for large files, not passwords or keys, so use the built-in options for secrets. GitLab documents that the built-in options cannot be used on forked projects, and that blob removal also excludes public upstream projects with downstream forks. See the GitLab Security Guide.
| Item | GitHub | GitLab |
|---|---|---|
| Where the rewrite runs | Locally with git-filter-repo, then force push | In the project UI (Remove blobs, Redact text) |
| Who can do it | Anyone who can force push all refs, plus a Support request | Project Owner role |
| Review refs and caches | Purged by GitHub Support on request | Cleared by housekeeping and Prune unreachable objects |
| Forks | Contact fork owners directly | Fork relationships can stop the process; archiving removes the relationship |
| What to keep for the record | Ticket number, first changed commits, PR count | Blob IDs or strings submitted, housekeeping and prune times |
4. Clean every other clone
- Preferred: everyone deletes their clone and clones again. CI caches, mirrors, build agents and backup copies count as clones too.
- If someone cannot re-clone: following the git-filter-repo manual, they delete all local tags and run
git fetch --prune --tags, rebase local work onto the new history (rebase, never merge: one merge commit brings the old history back), then rungit reflog expire --expire=now --allandgit gc --prune=now. The reflog step also drops their stash. - Do not rerun the rewrite in other clones: the manual warns that repeating the same git-filter-repo command elsewhere can produce different hashes and duplicate every commit.
- Orphaned LFS objects: delete them from
.git/lfs/objects/in local clones and from the LFS server.
Verify it worked
- Per clone:
git cat-file -t <first-changed-commit>must return a fatal error for every first changed commit. If it returnscommit, rungit for-each-ref --contains <hash>to find the ref still holding it. - On the server: a fresh clone searched with
git log -Sandgit log --all --name-statusmust find nothing. - Old links: on GitLab, GitLab documents that links to old commits return 404 after text redaction. On GitHub, check that old pull request diffs no longer show the data after Support confirms the purge.
- Scanner: rerun your secret scanner across full history and confirm the finding does not reappear. See the Git Secret Scanning Guide.
- Credential: confirm the old value fails authentication. This is the check that actually protects you.
Rollback and common errors
There is no rollback once the force push lands: GitHub and GitLab both describe the rewrite as permanent. Your safety net is before the push: keep the original clone (encrypted, access-limited) until verification passes, then destroy it, because it is also a copy of the secret.
| Symptom | Likely cause | Fix |
|---|---|---|
| Secret reappears days later | A pre-cleanup clone was pushed, or a branch was merged rather than rebased | Find the pusher in audit logs, repeat the rewrite, enforce re-cloning |
| Push rejected for some branches | Branch or tag protection | Lift protection for the window, push, restore |
Push rejected for refs/pull/* or refs/merge-requests/* | Read-only review refs | Expected: use the platform purge step |
| Commit still resolves in a clone | Old tags, stashes or reflog entries | Delete tags, expire reflogs, garbage collect, check again |
| Pipelines fail after rewrite | Jobs or deployments pinned to old commit SHAs | Update pinned references; GitLab notes this explicitly |
| Signed tags no longer verify | Signatures removed by the rewrite | Re-sign release tags and record the change |
Hardening afterwards
- Turn on push protection: both GitHub and GitLab document secret push protection that blocks commits containing recognised secrets. See the Push Protection Guide.
- Add a pre-commit scanner: the same hook for every contributor. See the Git Hooks and Pre-commit Security Guide.
- Ignore the file type: add the file pattern to
.gitignoreand commit it so the protection travels with the repository. See Environment Variables and .env Files. - Stage deliberately: GitHub advises avoiding
git add .andgit commit -a, and reviewinggit diff --cachedbefore each commit. - Remove the need for the secret: inject it at runtime from a secrets manager, or replace it with workload identity federation. See the Secrets Management Guide.
Where this sits in common frameworks (our mapping):
| Framework | Reference | How this task supports it |
|---|---|---|
| NIST CSF 2.0 | RS.MI-01, RS.MI-02 | Rotation contains the incident; the rewrite and purge eradicate the exposed data |
| NIST SP 800-53 Rev 5 | IA-5(7) No Embedded Unencrypted Static Authenticators | Removing the embedded secret and preventing its return |
| NIST SP 800-53 Rev 5 | IR-4 Incident Handling | Recorded containment, eradication and verification steps |
| MITRE ATT&CK | T1552.001 Credentials In Files; T1213.003 Code Repositories | The behaviours an attacker uses to harvest secrets from repositories and their history |
Practitioner checklist
- Revoke or rotate the credential first and record the time and any use found in provider logs.
- Decide, using the table above, whether a rewrite is needed at all.
- Freeze pushes and close or merge open pull and merge requests.
- Rewrite in a fresh clone with git-filter-repo 2.47+ and
--sensitive-data-removal; keep the first changed commits. - Inspect with
git log -Sand--name-statusbefore pushing. - Force push with
--mirror, restoring any branch protection you lifted. - Complete the platform purge: GitHub Support request, or GitLab Remove blobs or Redact text, housekeeping and prune.
- Get every clone, mirror and CI cache deleted or cleaned; contact fork owners.
- Verify with
git cat-file -tin each copy and a full-history scan. - Turn on push protection and pre-commit scanning, and record the incident.
Standards and references
- git-filter-repo: Manual, Sensitive Data Removals
- GitHub Docs: Removing sensitive data from a repository
- GitLab Docs: Repository size, remove blobs and redact text
- NIST: SP 800-53 Rev 5, Security and Privacy Controls
- NIST: Cybersecurity Framework 2.0
- MITRE ATT&CK: T1552.001 Credentials In Files
Related NHI Mgmt Group resources: Git Secret Scanning Guide · Push Protection Guide · Exposed .git Directory Response · OWASP NHI Top 10 NHI2 Secret Leakage Guide