You ran git add ., committed your .env, and pushed. Breathe. Then do these steps in order.
Step 1: Rotate every secret in the file (do this first)
Removing the file from history does not un-leak it. If it was pushed to a public repo, bots scan GitHub for keys continuously and can find them within minutes. Even in a private repo, every collaborator and every clone already has it.
For each value in the file:
- Create a new key in the provider's dashboard (AWS, Stripe, OpenAI, your database, and so on).
- Update it wherever it's used: hosting env vars, CI secrets, teammates' local files.
- Revoke the old key.
- Check the provider's logs for use you don't recognise.
Only once the old secrets are dead does cleaning history become a tidiness job rather than an emergency.
Step 2: Stop tracking the file
echo ".env*" >> .gitignore echo "!.env.example" >> .gitignore git rm --cached .env git commit -m "Stop tracking .env"
--cached removes it from Git but keeps your local copy.
If you haven't pushed yet, you can stop here and fix the last commit instead:
git rm --cached .env git commit --amend --no-edit
Step 3: Remove it from all history with git filter-repo
git filter-repo is the tool the Git project recommends for rewriting history. Install it:
brew install git-filter-repo # macOS pip install git-filter-repo # anywhere with Python
Work on a fresh clone. filter-repo insists on it, as a safety measure:
git clone https://github.com/you/repo.git repo-clean cd repo-clean git filter-repo --invert-paths --path .env
That rewrites every commit as if .env never existed. For several files, or a pattern:
git filter-repo --invert-paths --path .env --path config/secrets.json git filter-repo --invert-paths --path-glob '*.pem'
If the secret was pasted into a source file rather than living in its own file, replace the text instead:
# replacements.txt: one secret per line # sk_live_abc123==>REMOVED git filter-repo --replace-text replacements.txt
Check it worked:
git log --all --oneline -- .env # should print nothing
Step 4: Force-push the rewritten history
filter-repo removes the origin remote as a safety measure. Add it back and push every branch and tag:
git remote add origin https://github.com/you/repo.git git push origin --force --all git push origin --force --tags
You'll need to temporarily allow force-pushes if branch protection blocks them.
Step 5: Deal with everyone else's copies
Rewriting history changes every commit hash after the leak.
- Teammates must re-clone, or reset hard to the new history. If they merge their old branches in, the file comes back.
- Open pull requests may still reference old commits.
- Forks and caches keep the old data. On GitHub, cached views of old commits can stay reachable by hash. GitHub's docs on removing sensitive data explain how to contact support to purge them.
This is why Step 1 matters most. You can't guarantee every copy is gone, but you can make sure the leaked keys are useless.
The BFG alternative
BFG Repo-Cleaner is an older, Java-based tool that does the same job:
bfg --delete-files .env git reflog expire --expire=now --all && git gc --prune=now --aggressive
Either works. filter-repo is more flexible and actively maintained.
Make sure it never happens again
.gitignorefrom the first commit. Add.env*and commit a.env.examplewith placeholder values.- A pre-commit check. Tools like
gitleaksorgit-secretsblock commits that contain keys. Run them with Husky and lint-staged. - GitHub push protection. Secret scanning with push protection rejects pushes containing known key formats.
- Use prefixed keys in your own APIs so scanners can recognise them. See How to Generate Strong API Keys.
- Stop using
git add .without checkinggit statusfirst.