Removing a leaked secret from Git history Jump to heading

A committed credential is an incident, and the instinct to fix it with git rm and a tidy commit is exactly wrong: deleting a file in a new commit changes what the tree looks like now while leaving the secret readable in every earlier commit with a single git show. Removing it properly requires a history rewrite — but the rewrite is not the remediation. This recipe follows the sequence that actually ends the exposure, applying the rewriting mechanics from History Rewriting & Recovery in the right order.

When to use this approach Jump to heading

  • An API key, private key, database password or token was committed and pushed.
  • A secret-bearing file — .env, a service-account JSON, a keystore — was added before .gitignore covered it.
  • A pre-push secret scan fired after the fact, or a platform scanner reported a finding.
  • The repository must be made public and history has to be clean before that happens.
  • If the secret was committed but not pushed, this is much simpler: rotate anyway if it left your machine in any form, then rebase or amend locally with no coordination at all.

Step 1 — Rotate the credential before touching Git Jump to heading

Rotate first — the rewrite is cleanupRewriting first leaves the credential valid for the several hours a coordinated rewrite takes, so the exposure continues throughout. Rotating first invalidates the credential within minutes, after which the rewrite is unhurried housekeeping rather than an emergency.rewrite first — the common mistakecredential still valid — hours of coordination, backup, verification, re-clonerotated at lastthe whole red band is exposure you controlled and chose not to endrotate first — the correct orderminutescredential is dead — the rewrite is now unhurried housekeepinga rewrite can never reach forks, clones, CI caches or platform indexes — only rotation actually ends the exposure
# There is no Git command for this step. In your provider's console or CLI:
#   1. Issue a replacement credential
#   2. Deploy the replacement everywhere it is used
#   3. Revoke the leaked one
#   4. Review access logs for use of the leaked credential since it was committed

What changed: the string in history is now worthless. Everything that follows is hygiene, and it can proceed at a sensible pace.

Step 2 — Find every commit and every ref that contains it Jump to heading

# Which commits introduced or removed the string, across ALL refs
git log --all --oneline -S'AKIAIOSFODNN7EXAMPLE'

# Which paths have ever contained it
git log --all --name-only --format='%H' -S'AKIAIOSFODNN7EXAMPLE' \
  | grep -v '^$' | grep -v '^[0-9a-f]\{40\}$' | sort -u

# Is it present in tags as well as branches?
git for-each-ref --format='%(refname)' refs/tags \
  | while read -r t; do
      git grep -q 'AKIAIOSFODNN7EXAMPLE' "$t" 2>/dev/null && echo "present in $t"
    done
Why deleting the file is not enoughA deletion commit produces a tree without the secret, but every earlier commit still contains it and git show reads it directly. A rewrite replaces those earlier commits so no commit on any ref contains the value.git rm + commit — the secret stays readablekeykeykeyrmthe tree is clean here, and only heregit show HEAD~2:config.json ← still prints itgit filter-repo — the secret is in no commitc1'c2'c3'c4'every commit rewritten — and every SHA is newno ref anywhere reaches the old value

What changed: nothing — but --all is doing essential work here. A search that only covers the current branch routinely misses the secret on a release branch or an old tag, and a rewrite scoped to what that search found leaves it in place.

Step 3 — Purge it from all history Jump to heading

Work in a mirror clone. It is faster, it cannot disturb anyone’s working tree, and it keeps the original as a fallback.

# 1. A fresh mirror to rewrite in
git clone --mirror https://example.com/org/repo.git repo-rewrite.git
cd repo-rewrite.git

# 2a. If a whole file must go, remove the path from every commit
git filter-repo --path config/credentials.json --invert-paths

# 2b. If the secret is a string inside files that must stay, replace it
printf 'AKIAIOSFODNN7EXAMPLE==>REDACTED\n' > /tmp/replacements.txt
git filter-repo --replace-text /tmp/replacements.txt

What changed: every commit on every ref has been rewritten to exclude the path or redact the string, and every SHA from the earliest affected commit onward is new.

# The rewrite report names what it changed
git count-objects -vH
git log --all --oneline -S'AKIAIOSFODNN7EXAMPLE' | head    # expect no output

SAFETY WARNINGfilter-repo deliberately removes the origin remote after rewriting, so a stray git push cannot publish a half-finished rewrite. Do not add it back until Step 4 passes. When you do push, the target ref is almost certainly protected against rewrites — use the documented, logged override rather than disabling the protection.

Step 4 — Verify the secret is absent from every ref Jump to heading

Four places the secret survives an incomplete rewriteA rewrite scoped to one branch leaves the secret on other branches, in tags, in stashes and notes, and in the packed object store until it is repacked. Each must be checked explicitly before declaring the rewrite complete.other branchesrelease/*, long-lived integration branches,and anything not merged yetgit log --all -S'<secret>'tagsevery release tag points at a commit thatmay still contain itgit grep <secret> $(git tag)stashes and noteslocal-only refs a rewrite may skip,and they live on developer machinesgit stash list · refs/notes/*the object storeold objects linger until reflogs expireand the repository is repackedgit gc --prune=now
# 1. Nothing on any ref contains the string
git log --all --oneline -S'AKIAIOSFODNN7EXAMPLE' | wc -l    # expect 0

# 2. Nothing in any tag's tree
git grep 'AKIAIOSFODNN7EXAMPLE' $(git tag) 2>/dev/null | head   # expect no output

# 3. The old objects are actually gone from this mirror
git reflog expire --expire-unreachable=now --all
git gc --prune=now
git count-objects -vH

# 4. The current tree still builds — a rewrite should not change the present
git --work-tree=/tmp/verify checkout-index -a -f --prefix=/tmp/verify/

Step 5 — Publish and coordinate the re-clone Jump to heading

# Push every rewritten ref, once verification passes
git remote add origin https://example.com/org/repo.git
git push --force --all origin
git push --force --tags origin

Then the human half, which is where these migrations actually fail:

# What each colleague runs to confirm they are clean
git rev-parse HEAD                                        # matches the announced SHA
git log --all --oneline -S'AKIAIOSFODNN7EXAMPLE' | wc -l  # expect 0 in their clone too

The reason the “re-clone, not pull” instruction has to be repeated is that pulling appears to work. Git happily merges the colleague’s old history into the new one, restoring the secret, and nothing warns anyone. Verifying each clone is the only way to know the migration finished.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why rotate before rewriting instead of after? Jump to heading

Because the rewrite takes hours of coordination and the credential is exposed the entire time. Rotation ends the exposure in minutes and is entirely within your control, whereas the rewrite can never reach forks, clones, CI caches or platform indexes. Rewriting is cleanup; rotation is the remediation.

Is deleting the file in a new commit enough? Jump to heading

No. Deleting a file adds a commit in which the file is absent; every earlier commit still contains it, and anyone can read it with git show. That is the entire reason a rewrite is needed. A deletion commit is worth making anyway so the current tree is clean, but it must not be mistaken for remediation.

What if the repository is public and has forks? Jump to heading

Assume the secret is permanently public and act accordingly: rotate immediately, then check whether the credential was used in the interval. A rewrite cannot reach forks, and hosted platforms may retain the commit through their own references even after your push. Ask the platform to expire cached views, but do not let that request delay rotation.