Pushed an API key to Git? Rotate first, then clean history and prevent repeats
A token leaked to GitHub is not fixed by deleting the commit. Revoke it first, check usage logs, clean history with git filter-repo, then add gitleaks and push protection
- Step one is revoking and reissuing the key, not deleting the commit: bots find keys within minutes
- Check the old key's usage logs back to the moment of the push to learn whether anyone used it
- Clean history with git filter-repo --sensitive-data-removal and verify with git log -S before force-pushing
- Prevent repeats in three layers: .env plus .gitignore, gitleaks in pre-commit, and GitHub push protection
Committing a .env file or pasting an API token into code and pushing it to GitHub happens to every team sooner or later. What turns a small mistake into an incident is the order of the response. Most people rush to delete the commit while the key has already been read in the first minute. This article lays out the steps in the order that actually protects you, from the system owner’s side.
Tested on Git 2.45 and git-filter-repo 2.47.0 (against a throwaway repo with a fake key). The gitleaks commands were checked against the v8.30.1 source; the research figures against the original reports.
Why deleting the commit does not help
Once pushed, the value no longer lives in one place. GitHub’s own docs say that even after a history rewrite the old data survives in other people’s clones and forks, in pull requests that reference the commit, and in cached views reachable by SHA until GitHub Support clears them.
Worse is how fast the other side moves:
| Source | Finding |
|---|---|
| Orca Security, Honeypotting in the Cloud 2023 | An AWS key planted on GitHub was used within 2 minutes |
| Comparitech, GitHub honeypot | A dummy AWS key was found and abused within 1 minute |
| GitGuardian, State of Secrets Sprawl 2025 | 23.8 million new secrets in public GitHub commits in 2024, and 70% of secrets leaked in 2022 still worked |
The last row is the real lesson: the problem is not the leak, it is the leak that nobody rotates.
A key pushed to a public repo is a key someone else already has a copy of. Your first job is making that copy useless.
Step 1: revoke and reissue (now)
Open the key management page of that provider (cloud, payments, email API, LLM API):
- Create the new key first, put it wherever your system reads secrets (CI variables, a secret manager, the server’s
.env) and deploy so the system uses it - Revoke the old key (revoke / delete / disable), not just “stop using” it
- If production depends on the old key and cannot switch instantly, a few minutes of disruption beats leaving a leaked key live for hours
For public repos, GitHub’s secret scanning partner program sends an alert straight to participating providers, who may revoke the key or contact you. Do not wait for that. Not every provider takes part, and not every key format is detected.
If one key serves several jobs, this is when you pay for it: every system must switch at once. Splitting keys by job ahead of time is covered in one key per job.
Step 2: check whether anyone used it
Revoking stops future use. You still need to know what happened while the key was exposed.
- The window starts at the time of the push (from
git logor the commit page), not when you noticed - Read the provider’s logs: the cloud audit log, the API usage/billing page, the key’s call history. Look for IPs or regions that are not yours, new resources (machines, users, new keys), and usage spikes
- If the key could create other credentials, look for keys or users you did not create. Revoking the original does not remove what was created with it
- Write down times and findings. If customer data was involved you may have a legal duty to notify
Step 3: clean history with git filter-repo
Do this only after the key is dead. The goal is that the value no longer sits in the repo, scanners stop re-alerting, and nobody copies it as an example. GitHub recommends git-filter-repo 2.47 or later, which has a --sensitive-data-removal mode (pip install git-filter-repo).
Work in a fresh clone, not your working folder:
git clone https://github.com/example/app.git app-clean
cd app-clean
# Case 1: drop .env from every commit
git filter-repo --sensitive-data-removal --invert-paths --path .env
# Case 2: the key is inside a code file, replace the value
# replacements.txt holds one value per line: old==>new
echo 'sk_test_EXAMPLE1234567890abcdef==>REMOVED' > ../replacements.txt
git filter-repo --sensitive-data-removal --replace-text ../replacements.txt
replacements.txt contains the real key, so keep it outside the repo and delete it afterwards. If the file was ever moved or renamed, pass every path with --path.
Always verify before pushing. In our test the first command rewrote 2 of 3 commits, and the checks below came back empty:
git log --all --oneline -S "sk_test_EXAMPLE" # must print nothing
git log --all --oneline -- .env # must print nothing
git grep "sk_test_EXAMPLE" $(git rev-list --all) # must print nothing
Then overwrite the remote:
git push --force --mirror origin
filter-repo prints the First Changed Commit(s) at the end. Keep them.
- Pull requests that reference old commits live under
refs/pull/*, which you cannot force-push. Open a ticket with GitHub Support with the repo name, the number of affected PRs and the First Changed Commit, and they can remove cached views and garbage-collect orphaned commits - Teammates must delete old clones and re-clone. One
git pullfollowed bygit pushfrom an old clone brings the key straight back - Every commit hash after the change point changes; open PRs and commit signatures are affected. Tell the team first
Step 4: stop it happening again
Layer 1: keys live in .env, and .env lives in .gitignore. Code reads from the environment only; commit an example file without real values.
echo ".env" >> .gitignore
git rm --cached .env # if it was already tracked, .gitignore alone does nothing
cp .env .env.example # then blank the values in .env.example
git add .gitignore .env.example
git commit -m "stop tracking .env"
Layer 2: gitleaks in pre-commit. It scans only what you are about to commit and fails the commit if it finds a secret. Through the pre-commit tool:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
pip install pre-commit
pre-commit install # installs the hook into this clone's .git/hooks
The hook runs gitleaks git --pre-commit --redact --staged --verbose for you. To scan the whole history once, run gitleaks git -v. Note that hooks are per machine, can be skipped with --no-verify, and do nothing for anyone who never ran pre-commit install, so run gitleaks in CI as well.
Layer 3: GitHub push protection. Server-side, it blocks pushes containing known key formats. For user accounts it is on by default for pushes to public repos. At repository level you enable it yourself, and it needs GitHub Secret Protection. Anyone with write access can bypass it with a reason; each bypass creates an alert and emails the admins.
These layers catch keys with a recognisable format. Generic passwords or tokens without a prefix can slip through. The rule that really works is no secrets in code at all.
Common mistakes
- Cleaning history first, rotating later. While you read the filter-repo manual, the key still works
- Making the repo private and calling it done. Copies already cloned do not disappear
- Adding
.envto.gitignorewithoutgit rm --cached. Git keeps tracking files it already tracks - Force-pushing without telling the team. Someone’s old clone pushes the old history back
- Skipping the provider’s logs. The key is revoked, but an attacker-created key is still live
- Real keys in issues, wikis or CI logs. These are not in git history, filter-repo does nothing; delete and rotate there too
If your team pulls many third-party packages, read staying safe with npm and pip, another route by which keys on a dev machine leak.
Sources
- Removing sensitive data from a repository — GitHub Docs
- About push protection — GitHub Docs
- About secret scanning for partners — GitHub Docs
- gitleaks — GitHub
- The State of Secrets Sprawl 2025 — GitGuardian
- 2023 Honeypotting in the Cloud Report — Orca Security
- It takes hackers 1 minute to find and abuse credentials exposed on GitHub — Comparitech
FAQ
Is making the repo private, or deleting it, enough?
No. If the repo was public even for a few minutes, treat the key as leaked. Someone may have cloned or forked it, and automated scanners have already read it. The only thing that closes the risk is revoking the key at the provider.
The repo was always private. Do I still need all of this?
Rotate anyway. Everyone with read access, your CI, every laptop with a clone and every backup holds the key. Cleaning history stops scanners from re-alerting and stops a second leak if the repo ever goes public.
Can I just use git rebase or git commit --amend?
Only if the commit was never pushed. Once pushed, the old value lives in other clones, forks and caches, so rewriting history is cleanup, not a way to take the secret back.
Commands and settings were tested on sample systems. Try them on a test machine before production.