Removing a Leaked API Key from Git History
A live API key like AKIAIOSFODNN7EXAMPLE landed in a commit, was pushed to your shared remote, and now shows up in git log -p for every clone. This page gives a deterministic, CLI-driven procedure to strip that value from every commit with git filter-repo or the BFG Repo-Cleaner, rotate the leaked credential, and force-push the rewritten history safely — it sits under scanning and preventing secret leaks and the wider environment sync and CI parity baseline.
Deleting the file in a new commit does nothing: Git keeps every historical blob, so the key stays reachable through git show <old-sha>:path and through the reflog. Removing a leaked secret means rewriting history so the value never appears in any commit object, then rotating the credential because the old value is already compromised the moment it reached a remote. The steps below do both, in the order that avoids a broken shared repository.
Diagnostic
First confirm the key is actually in history and find every commit and path that carries it. The pickaxe flag -S walks all commits and prints the ones where the string's occurrence count changed:
#!/usr/bin/env bash
set -euo pipefail
LEAK='AKIAIOSFODNN7EXAMPLE'
# Every commit that added or removed the literal value, across all refs
git log --all --oneline --source -S "$LEAK"
# Every historical blob path that still contains it
git grep -n "$LEAK" $(git rev-list --all) | head -n 20
Expected BAD output — the key was introduced three commits back and still lives in a committed .env:
9f3a1c2 (refs/heads/main) feat: wire up S3 upload client
main~2:services/uploader/.env:AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
9f3a1c2:services/uploader/.env:AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
7b21e40:services/uploader/config.py: key = "AKIAIOSFODNN7EXAMPLE"
The git grep across git rev-list --all is the authoritative test: it searches the tree of every commit, so it catches the value even where a later commit "deleted" the file. Note the two distinct paths in the output — .env and config.py. History surgery scoped to a single filename would miss the copy hard-coded in config.py, so record every path the grep reports before choosing a strategy. The decision below turns that diagnostic into a concrete tool choice.
Root cause
Git is content-addressed and append-only. Every version of every file is stored as an immutable blob keyed by its SHA-1/SHA-256 hash, and commits reference the tree of blobs that existed at that point. Adding a "remove the key" commit only writes a new tree that omits the file; the original blob is never deleted — it stays in .git/objects, referenced by the old commit, and remains fully readable with git cat-file blob <sha>. Because the leaked commit was already pushed, that same blob now exists in the remote and in every teammate's clone and every CI cache. There is no in-place edit that reaches back and mutates a historical commit, so the only way to eliminate the value is to rewrite every commit from the first offending one forward, producing brand-new commit hashes that never contained it — and then to treat the exposed key as burned, because containment is not the same as revocation.
Resolution
Work on a fresh mirror so a mistake never touches your working repository. The ordered sequence is: back up, rewrite, verify, rotate, force-push, re-clone.
- Install the maintained rewrite tool and make a throwaway mirror clone as a backup:
#!/usr/bin/env bash set -euo pipefail python3 -m pip install --user git-filter-repo git clone --mirror [email protected]:acme/uploader.git uploader-backup.git cp -a uploader-backup.git uploader-rewrite.git - Write a replacement file listing the literal key and any pattern variants, then rewrite every commit.
git filter-reporeplaces the matched text in every blob and rewrites all reachable commits:#!/usr/bin/env bash set -euo pipefail cd uploader-rewrite.git cat > /tmp/leak-patterns.txt <<'EOF' AKIAIOSFODNN7EXAMPLE==>AWS_ACCESS_KEY_ID_REMOVED regex:sk_live_[A-Za-z0-9]{24}==>STRIPE_KEY_REMOVED EOF git filter-repo --replace-text /tmp/leak-patterns.txt - If the secret lived only in a dedicated file that should never have existed, purge the path itself instead of the value:
#!/usr/bin/env bash set -euo pipefail cd uploader-rewrite.git git filter-repo --path services/uploader/.env --invert-paths - Verify the value is gone from every commit before you publish anything:
#!/usr/bin/env bash set -euo pipefail cd uploader-rewrite.git if git grep -n 'AKIAIOSFODNN7EXAMPLE' $(git rev-list --all); then echo "STILL PRESENT — do not push" >&2 exit 1 fi echo "Clean: key absent from all reachable commits" - Rotate the exposed credential in the provider console (revoke
AKIAIOSFODNN7EXAMPLE, issue a new key) and load the replacement through your secret manager rather than a file. Rotation is non-negotiable and comes before the force-push so a valid key is never the only copy on a soon-to-be-orphaned history — the runtime swap mechanics live in rotating secrets without restarting containers. - Publish the rewritten history and prune the old objects locally:
#!/usr/bin/env bash set -euo pipefail cd uploader-rewrite.git git remote add origin [email protected]:acme/uploader.git git push --force-with-lease --all origin git push --force-with-lease --tags origin git reflog expire --expire=now --all git gc --prune=now --aggressive
Step 1 matters because git filter-repo refuses to run in a non-fresh clone by default — the mirror gives it that clean starting point and gives you a restore path if the rewrite is wrong. Step 2 rewrites content and preserves every path, which is what you want when the key is embedded in source files you intend to keep. Step 3 rewrites structure by dropping a path entirely; use one or the other based on the diagnostic grep, not both blindly. Step 6 uses --force-with-lease rather than --force so the push aborts if someone pushed to the remote after you cloned it, preventing you from silently clobbering a teammate's new commit.
The BFG Repo-Cleaner is the faster alternative when the job is exactly "replace this string everywhere" and you do not need filter-repo's finer path controls. It reads the same kind of replacement list and never touches your most recent commit (which it assumes is already clean):
#!/usr/bin/env bash
set -euo pipefail
cat > replacements.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>AWS_ACCESS_KEY_ID_REMOVED
EOF
java -jar bfg-1.14.0.jar --replace-text replacements.txt uploader-rewrite.git
cd uploader-rewrite.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
Expected output
A successful git filter-repo run reports the rewrite, and the verification grep from step 4 finds nothing:
Parsed 431 commits
New history written in 2.14 seconds; now repacking/cleaning...
Completely finished after 3.02 seconds.
Clean: key absent from all reachable commits
The Parsed N commits line confirms filter-repo walked the whole history, and New history written means every commit now carries a fresh hash. The Clean: line is the one that gates the push — if the grep in step 4 had matched, the script would have exited non-zero with STILL PRESENT — do not push and you would return to the pattern file to add the missed variant before publishing. The BFG prints a slightly different summary — a table of Changed files and a BFG run is complete! banner — but the meaning is the same, and it too leaves the final cleanup (reflog expire plus gc --prune=now) to you. Whichever tool you used, do not skip that prune step: until the loose objects are gone the old blob is still reachable through the reflog on your machine, and a careless git push of a stale ref can send it straight back to the remote.
Prevention
- Add a gitleaks pre-commit hook so a key is blocked at staging time, long before it can reach a remote. Managing the wider ignore boundary is covered in managing local secrets without committing to Git:
# .pre-commit-config.yaml repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.18.4 hooks: - id: gitleaks - Enforce the same scan in CI so a hook bypassed with
--no-verifystill fails the pipeline:# .github/workflows/secret-scan.yml name: secret-scan on: [push, pull_request] jobs: gitleaks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: gitleaks/gitleaks-action@v2 env: GITLEAKS_LICENSE: "" - Turn on the host's server-side push protection (GitHub Push Protection or GitLab secret detection) so a matching pattern is rejected at
git push, giving you a backstop that no local misconfiguration can disable.
The three layers are complementary. The pre-commit hook is the fastest feedback and stops the mistake at the source; the CI job catches anything that skipped the hook; and server-side push protection is the last line that even a fresh clone with no hooks installed still has to pass. Blocking the leak up front is always cheaper than the history rewrite this page describes, and it saves you the force-push coordination cost entirely.
Platform caveats
WSL2: Run
git filter-repoagainst a repository stored in the native Linux filesystem (~/src/...), not a Windows mount under/mnt/c. Rewriting hundreds of commits over the 9p mount is dramatically slower and can corrupt the pack if the mount drops mid-gc.
macOS (Docker Desktop):
git filter-branchships with macOS Git but is deprecated and O(commits) slow; installgit-filter-repoviabrew install git-filter-repoand use it instead. The stockfilter-branchwarning it prints is not a bug — it is telling you to switch.
Apple Silicon (ARM64): The BFG needs a JVM; install
temurinfrom Homebrew's ARM build sojava -jar bfg.jarruns natively rather than under Rosetta emulation, which otherwise adds noticeable overhead on large repositories.
Rollback
If the rewrite went wrong — a needed file dropped, or hashes changed in a way that broke a downstream integration — restore from the mirror you made in step 1 and force-push it back:
#!/usr/bin/env bash
set -euo pipefail
cd uploader-backup.git
git push --force --all origin
git push --force --tags origin
echo "Restored pre-rewrite history; the rotated key stays rotated"
The restore brings back the old commit hashes, but note the last line: rotation is not reversible and should not be reversed. Even if you roll the history back, the key you revoked in step 5 stays dead, because it was already exposed. Rolling back only undoes the surgery, never the compromise. Once the corrected rewrite is ready, re-run the resolution from step 2 and have every collaborator re-clone — a teammate who pulls into an existing checkout can reintroduce the old objects on their next push, silently resurrecting the removed blob.
Frequently Asked Questions
Why not just commit a fix that deletes the file with the key?
Because Git never deletes historical blobs. A delete commit writes a new tree that omits the file, but the original blob stays in the object database and is readable with git show <old-sha>:path or git cat-file blob <sha> in every clone. Only rewriting the offending commits — so the value never appears in any commit object — actually removes it.
Do I still need to rotate the key after rewriting history?
Yes, always. History rewriting removes the value from your repository, but any clone, fork, or CI cache taken before the rewrite still holds it, and automated scanners routinely harvest pushed keys within seconds. Treat the credential as compromised from the moment it was pushed and revoke it. The rewrite is containment; rotation is the actual remediation.
Should I use git filter-repo or the BFG Repo-Cleaner?
Use git filter-repo when you need precise control — replacing a value while keeping the file, purging a specific path, or filtering by commit. Use the BFG when the job is simply "replace this string everywhere" and you want the fastest run; it is purpose-built for that narrow case and protects your latest commit automatically. Both require a fresh clone and a git gc --prune=now afterward.
Why --force-with-lease instead of --force?
--force-with-lease aborts the push if the remote moved since you cloned it, so you cannot silently overwrite a commit a teammate pushed while you were rewriting. Plain --force overwrites unconditionally and can destroy work. After the rewrite, announce the change so everyone re-clones rather than merging the old history back in.