How to remove an API key from git history (rotate first)

Rotate the key first, then clean the history. Once the old key is revoked, it opens nothing, so every copy of the commit that you can't reach stops mattering. After that, git filter-repo with its --sensitive-data-removal option rewrites every commit in a fresh clone, git push --force --mirror replaces the history on the server, and GitHub Support removes the copies GitHub keeps for pull requests and cached views.

The rewrite is the slow, careful part, and it never reaches everything. Forks keep the commit, and so does every clone made before your cleanup. That's why it comes second. I tested the git and git-filter-repo commands below with git 2.54 and git-filter-repo 2.47 on a throwaway repository with a made-up key, and the output shown is what they printed. The BFG commands come from BFG's own page.

Why rotate the key before you rewrite history?

Because rotating fixes the problem and rewriting only tidies it. GitHub's guide to removing sensitive data says it directly: if the data is a secret, "as a first step you need to revoke and/or rotate that secret", and once it's rotated, "that may be sufficient to solve your problem." The git-filter-repo manual agrees: rotating "may resolve your problem without resorting to the heavy-handed action of rewriting and purging history."

A rewrite can't reach the copies that matter most:

  • Forks. GitHub's guide: if the commit "exists in any forks, it will continue to be accessible there." Truffle Security showed in 2024 that data from deleted forks stays reachable by commit hash: "As long as one fork exists, any commit to that repository network ... will exist forever." Surveying three commonly forked repositories, they found 40 valid API keys in deleted forks.
  • Other people's clones. "You cannot remove sensitive data from other users' clones of your repository," says GitHub.
  • Anyone who already copied it. Bots that watch public pushes don't wait for your cleanup. My post on the first ten minutes after a leak covers that part, with rotation links for common providers.

GitHub Support, which you need for the last step, says the same thing in its own way: it "will only assist in the removal of sensitive data in cases where we determine that the risk can't be mitigated by rotating affected credentials."

Five steps: rotate the key, check what it did, rewrite a fresh clone, force-push every ref, clean up the copies
History comes after the key is dead, not before.

Do you need to rewrite history at all?

Often not, once the key is rotated. It depends on where the commit went:

Where the key is Rotate it? Rewrite history?
Last commit, not pushed Only if it left your machine some other way No: amend the commit (below)
Pushed to a private repo few people cloned Yes Usually worth it, and cheap
Pushed to a public repo Yes, right now Optional: forks and caches keep a copy anyway
Data you can't rotate (customer data, a personal file) Not possible Yes, and contact GitHub Support

Rewriting is still worth doing for a rotated key when scanners keep flagging it, when a private repository is about to go public, or when your company's policy asks for it. Don't let it delay the rotation.

If you haven't pushed yet

A key in your last local commit is the easy case. Take the file out of the commit, keep your copy on disk, and ignore it from now on:

Terminal
$ git rm --cached .env
$ echo ".env" >> .gitignore
$ git add .gitignore
$ git commit --amend --no-edit

The amended commit no longer contains .env. If the key is in an older commit, use the git filter-repo steps below.

How do I remove a key with git filter-repo?

git filter-repo is the tool GitHub's guide uses, and git's own documentation points to it: the git filter-branch page says filter-branch's "use is not recommended. Please use an alternative history filtering tool such as git filter-repo." It's a single Python script that needs git 2.36 or later and Python 3.6 or later. The --sensitive-data-removal option needs version 2.47 or later.

  1. Install it. On a Mac, brew install git-filter-repo. For other systems, its install guide explains how to drop the script into your PATH.
  2. Tell everyone to stop pushing, and merge or close open pull requests. Commit hashes are about to change, and work pushed during the cleanup gets lost or brings the key back.
  3. Make a fresh clone. filter-repo refuses to rewrite a repository that doesn't look freshly cloned, which protects work you haven't pushed.
Terminal
$ git clone https://github.com/you/acme-api.git
$ cd acme-api
  1. Find every copy. Search all branches and tags, by file name and by a piece of the key. Stripe live keys start with sk_live_; use whatever prefix or fragment your key has:
Terminal
$ git log --all --oneline --name-status -- .env
23564e2 add stripe
A	.env
$ git log -S'sk_live_' --all --oneline
23564e2 add stripe
  1. Remove the file from every commit, if the whole file should go. Give the path from the repository root, and add another --path for any old name the file had:
Terminal
$ git filter-repo --sensitive-data-removal --invert-paths --path .env
You rewrote 2 (of 3) commits.
 
NOTE: First Changed Commit(s) is/are:
  23564e2c3eb977c689701d6a70ed4245d4aa973f

(That's an excerpt; the full output ends with a list of next steps.) Keep the First Changed Commit hash: GitHub Support and your colleagues will need it.

  1. Or replace only the value, if the key sits in a file you want to keep, such as config.js. Write the rules in a file outside the repository. Each line is a literal string, a glob: or a regex:, and the text after ==> replaces it. Without ==>, filter-repo writes ***REMOVED***.
text
regex:sk_live_[0-9A-Za-z]{24,}==>STRIPE_KEY_REMOVED
Terminal
$ git filter-repo --sensitive-data-removal --replace-text ../expressions.txt

Running filter-repo a second time on the same clone is fine. In my test, the second run still reported the original First Changed Commit.

  1. Check that it's gone. Both searches from step 4 should now print nothing:
Terminal
$ git log --all --oneline --name-status -- .env
$ git log -S'sk_live_' --all --oneline
  1. Count the pull requests you're about to affect. GitHub's guide uses this file, which filter-repo writes:
Terminal
$ grep -c '^refs/pull/.*/head$' .git/filter-repo/changed-refs
  1. Force-push every ref. This replaces all branches and tags on the server, and discards anything others pushed meanwhile:
Terminal
$ git push --force --mirror origin

Pushes to refs/pull/... fail: GitHub marks them read-only, and Support deals with them. If any other ref fails, branch protection is probably blocking force pushes; turn it off for the push, then back on.

What's left after the force-push?

Three kinds of copies, and none of them are yours to delete.

GitHub's own copies. Open a ticket in the GitHub Support portal with the repository name, the number of affected pull requests from step 8, and the First Changed Commit hashes. Once only pull request refs are left, and no fork has the commit, Support dereferences or deletes the affected pull requests, runs a garbage collection on the server and removes cached views. On GitLab, a project owner can do this without a ticket: GitLab's repository size docs describe Remove blobs and Redact text under Settings > Repository > Repository maintenance.

Old clones. The simplest fix is for everyone to delete their clone and clone again. If someone can't, the filter-repo manual gives them steps: delete all tags and fetch them again, rebase their own branches onto the new history (rebase, not merge: one merge can bring the old history back), then expire the reflog and prune. I ran them in a teammate's old clone, where I had no local work, so a reset stood in for the rebase:

Terminal
$ git tag -l | xargs git tag -d
Deleted tag 'v0.1' (was 745c17b)
$ git fetch --prune --tags
$ git reset --hard origin/main
$ git reflog expire --expire=now --all
$ git gc --prune=now
$ git cat-file -t 23564e2c3eb977c689701d6a70ed4245d4aa973f
fatal: git cat-file: could not get object info

The last command is the check: an error means the old commit is gone from that clone. If it prints commit, something still holds it.

Forks. You have to ask each fork's owner to remove the data or delete the fork. GitHub's guide notes it "cannot provide contact information for these owners."

Forks, old clones and GitHub's own copies all still hold the old key; if it was rotated first, every copy holds a dead key
A force-push cleans one copy. Rotation covers all of them.

Can I use BFG Repo-Cleaner instead?

Yes. BFG Repo-Cleaner needs Java 11 or later and does the two common jobs well: deleting files and replacing passwords listed in a file. Its page shows this flow, where bfg stands for java -jar bfg.jar:

Terminal
$ git clone --mirror https://github.com/you/acme-api.git
$ bfg --delete-files .env acme-api.git
$ cd acme-api.git
$ git reflog expire --expire=now --all && git gc --prune=now --aggressive
$ git push

One behaviour catches people out. By default BFG "doesn't modify the contents of your latest commit", on the reasoning that your latest commit is what you deploy. If the key is still in your current files, commit its removal first, then run BFG on the older history. BFG also has no sensitive-data mode, so it won't report the first changed commit that GitHub Support asks for. For a secret on GitHub, I'd use filter-repo.

A comparison of git filter-repo and BFG: GitHub's guide, a sensitive-data mode, removing files, replacing text, the latest commit and what each needs
Both work. filter-repo matches what GitHub Support asks for.

How do I stop it from happening again?

A history rewrite is work you don't want to repeat. These habits prevent it:

  1. Keep keys out of the repository. Load them when the program starts instead of from a committed file. With Keyfold that's keyfold run -p acme-api -e development -- npm start: keys are kept by project and environment in one encrypted file, and .env import moves existing files in.
  2. Ignore every variant of .env, and check what git already tracks with git ls-files | grep -i env. My post on how .env files leak has the full set of fixes.
  3. Scan before each commit. A pre-commit hook running a scanner such as gitleaks catches most keys before they leave your laptop.
  4. Keep GitHub's push protection on. For your own account it's on by default, and blocks secrets it recognises from reaching public repositories.
  5. Watch what your AI agent commits. Agents make commits too, and one that can read .env files can copy a key into code. Fix that first.

The security page explains how Keyfold stores keys, and the live demo shows the vault with made-up keys.

FAQ

Does deleting the file in a new commit remove the key?

No. A new commit removes the file from that commit onwards. Every earlier commit still contains it, and anyone can check out an earlier commit.

Is git filter-branch still a good choice?

Git's own documentation says its use "is not recommended" and points to git filter-repo instead. It's slow and easy to get wrong.

Will rewriting history break my pull requests?

Closed pull requests lose their diff view, including ones merged after the key was added. Open pull requests get different diffs and can lose review comments, which is why GitHub recommends merging or closing them first.

Do I need GitHub Support for a private repository?

Only if you need GitHub's cached views and pull request refs purged too. For a rotated key in a private repository, a rewrite and fresh clones for everyone is usually enough.

What if I can't rotate the key?

Then the history rewrite and GitHub Support's cleanup are your main defence, and forks and old clones are real risks. Restrict what the key can reach, watch its usage, and replace it as soon as the provider allows.