Committed a Secret to Git? Rotate the Key First, Then Clean History with filter-repo

6 min read

You committed an entire .env file, or an AWS access key and API token hardcoded in the source slipped into a commit — and the push has already gone out. The most common first reaction to this incident is “delete it from history, fast.” The correct order is the reverse. Priority one is not cleaning history but invalidating the key. This post starts with why that is, then walks through the cleanup procedure for each situation and the prevention tooling.

Rotate the key first #

A pushed secret should be treated not as information that can still be deleted but as information that has already leaked.

  • On a public repository, bots harvest it within minutes. Automated tools constantly scan GitHub’s public event stream collecting credentials. It is safest to assume the key was in someone else’s hands minutes after the push.
  • Copies are not erased by cleaning the origin. Forks, teammates’ clones, and the checkouts and logs your CI pulled already hold copies. Rewriting the origin repository’s history does nothing to those copies.

So before any cleanup work, revoke the credential at its issuer and issue a new one. Deactivate and delete an AWS access key in IAM, revoke GitHub tokens and third-party API keys in each service, and if it was a database password, change the password itself. From the moment the key is invalidated, the string left in history becomes a dead value that no longer poses a risk.

Note
For cloud credentials, check for abuse alongside the invalidation. On AWS, review the key’s recent call history in CloudTrail. If there are entries after the leak window — resources created in an unfamiliar region, for example — this is no longer just key revocation but an incident-response case.

Where you stand — before or after push #

After key rotation comes history cleanup. The procedure differs completely depending on whether the commit reached the remote.

  • Not pushed yet — the commit exists only on your machine, so rewriting locally is the end of it.
  • Already pushed — it has spread to the remote and every copy, so you need a dedicated tool to rewrite history plus cooperation from the whole team.

Before push — clean up locally #

If the secret went into the commit you just made, two commands clean it up.

Remove the file from the last commit
git rm --cached .env          # untrack only; the file stays in the working directory
echo ".env" >> .gitignore
git add .gitignore
git commit --amend --no-edit  # replace the last commit with a new one

git rm --cached does not delete the file — it only removes it from Git’s tracking. Following up with --amend replaces the last commit with one that carries no secret.

If the secret entered a few commits back, fix that commit with an interactive rebase.

Remove it from an earlier commit
git rebase -i <commit just before the one with the secret>
# change that commit to "edit" in the todo list

git rm --cached .env
git commit --amend --no-edit
git rebase --continue

In this case key rotation is a judgment call. If the commit never left your machine, there was no leak — but if you cannot be sure (say, it went up to a personal remote even once), rotating is the safer side.

After push — rewrite history with filter-repo #

Commits that reached the remote require a tool that rewrites the entire history. The standard tool is git filter-repo. The older git filter-branch is slow and full of traps — Git’s own documentation recommends filter-repo instead.

Install filter-repo
pip install git-filter-repo
# or on macOS: brew install git-filter-repo

To keep you from wrecking a working repository by mistake, filter-repo expects to run in a freshly cloned, clean repository. Take a dedicated cleanup clone first.

Remove a file — delete .env from the entire history
git clone https://github.com/example/my-service.git cleanup
cd cleanup
git filter-repo --invert-paths --path .env

The default behavior of --path is to keep the specified file, so adding --invert-paths flips it: only that file is removed from the entire history. If the file must stay and only the key string inside the code needs replacing, use a replacement-rules file.

expressions.txt — replacement rules
AKIAIOSFODNN7EXAMPLE==>***REMOVED***
regex:AKIA[0-9A-Z]{16}==>***REMOVED***
Run the string replacement
git filter-repo --replace-text expressions.txt

The cleaned history is an entirely new history in which every commit hash has changed. filter-repo removes the remote configuration for safety, so reconnect the remote and push by force.

Push the rewritten history
git remote add origin https://github.com/example/my-service.git
git push --force --all
git push --force --tags

The last step matters most. Tell every team member to discard their existing clones and clone fresh. A clone that still carries the old history becomes the path through which the deleted commits resurrect on the remote via a pull or push. History rewriting is complete not when the tool finishes but when this coordination does.

Tip
BFG Repo-Cleaner is an alternative tool for the same job. Its rules are simpler, while filter-repo offers finer control. Whichever you pick, the procedure is identical: work in a fresh clone, force push, and announce the re-clone.

Where traces remain even after cleanup #

Even with the origin repository perfectly cleaned, traces can remain on the GitHub platform.

  • The history of existing forks persists regardless of the origin cleanup.
  • PR diff views and cached views reachable directly by commit hash can stay accessible for a while after the commit disappears from the repository history.

Repository owners cannot delete these areas themselves — clearing the caches and handling forks requires a request to GitHub Support. Put differently, no matter how well you clean history, complete deletion is not guaranteed, which is the second reason key rotation comes first.

Prevention #

The guards against repeating this incident can be layered starting before the commit ever happens.

  • Register .gitignore first — make it a habit to add files like .env and *.pem before creating them, exactly the principle covered in Git Basics #2.
  • GitHub push protection — with push protection enabled under the repository’s secret scanning settings, GitHub blocks a push containing known credential formats at the receiving end.
  • A pre-commit hook — wiring a scanner like gitleaks into a pre-commit hook filters the secret out locally before the commit is even created.
  • Keep keys out of the code — inject them through environment variables and a secrets manager (AWS Secrets Manager and the like) so keys never exist in the code or the repository, and keep only a value-less .env.example in the repo.

Wrap-up #

The response order, once more.

  1. Rotate — revoke and reissue at the issuer immediately. A pushed key counts as already leaked.
  2. Clean — before push, use amend or rebase; after push, rewrite history with filter-repo in a fresh clone.
  3. Announce — after the force push, have the whole team re-clone. Forks and caches go through GitHub Support.
  4. Prevent — .gitignore first, push protection, a pre-commit scanner, and a secrets manager stop the repeat.

History cleanup is the finishing work that removes the visible traces; the actual closure of the incident is done by key invalidation. Keep the order straight and this incident ends as a recoverable mishap.

X