Find secrets in git history: search, rotate, then purge
You can find secrets in git history with git log -S, gitleaks or trufflehog, and you should, because deleting a key in a later commit leaves it in every old commit. If you find one, revoke it first, meaning disable it at the provider so it stops working. Removing it from history comes second and doesn't un-leak anything. The commands below run on your own repo.
QUICK CHECKLIST
- Search all branches with git log -p -S and --all
- Run gitleaks git and trufflehog on a fresh clone
- Revoke the key before you touch history
- Purge with git filter-repo, then force-push
- If rotating isn't enough, ask GitHub Support to clear caches
- Add .gitignore and a gitleaks pre-commit hook
Why a deleted key is still in your git history
Git keeps every commit you've ever made. Deleting .env in a new commit gives you a new snapshot without the file, but the commit that added it still exists, and anyone who can read the repo can read it.
The same key sits in other branches, tags, forks and teammates' clones. Rewriting history in your copy doesn't reach those.
How AI tools and bots turn old commits into leaks
AI coding tools often stage everything (mark every changed file for the next commit) with git add .. If there's no .gitignore yet (the file that tells git which files to skip), your .env goes in with the first commit. Hardcoded keys do the same thing: an sk_live_ Stripe key or a Supabase service_role key pasted into a file so the demo works.
Then you notice and ask the tool to fix it. The usual fix is a later commit that deletes the file or swaps the key for process.env. The original commit stays.
git rm .env git commit -m 'remove env file' git push # the commit that added .env is still in history
# 1. Revoke the leaked key in the provider dashboard # 2. Ignore env files and stop tracking them echo '.env' >> .gitignore echo '.env.*' >> .gitignore echo '!.env.example' >> .gitignore git rm --cached .env git add .gitignore git commit -m 'ignore env files' # 3. Put the new key in your host's env settings
Scrapers read old commits on public repos at scale, so don't count on nobody noticing. What a stolen key can do depends on the key:
- A Supabase
service_rolekey bypasses row level security (the database rules that limit each user to their own rows). The anon key is meant to be public. - A Stripe
sk_live_key can create charges, issue refunds and read customer data.
Our leaked API key guide covers what to check after a leak.
Find secrets in git history with git and scanners
Run all of this on your own repo. Start from a fresh git clone, then fetch pull request refs, because GitHub keeps them after the branch is deleted:
git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'
A git log search for an API key starts with the pickaxe flag. -S finds commits that change how many times a string appears. -G finds commits whose diff adds or removes a line matching a regex. --all includes every branch, tag and the PR refs you just fetched.
# Every commit that added or removed this string, with the diff git log -p -S 'service_role' --all # Same idea with a regex git log -p -G 'sk_live_[A-Za-z0-9]+' --all
To read a deleted file, run git show COMMIT_HASH^:PATH, using the commit that removed it and the file's full path.
A gitleaks scan reads every commit's patch with rules for many key formats. --redact keeps the key out of your terminal and CI logs, and the exit code is 1 when it finds something.
brew install gitleaks gitleaks git -v --redact
TruffleHog recognizes many secret types and tests keys against the provider to see if they're still live, for the types it can verify. verified means the test login worked. For a history audit, run it without a results filter, since an unverified result is not proof the key is dead.
brew install trufflehog trufflehog git file://.
| What it checks | Watch out for | |
|---|---|---|
| git log -S / -G | Any text you search for, in every commit | You have to know what to search for |
| gitleaks git | Every commit's patch, using built-in rules | A match isn't proof the key is live |
| trufflehog git | Many secret types, and can test if a key is live | Verifying means it tries the key against the provider |
| GitHub secret scanning | Full history on all branches, plus issues and PRs | Only supported patterns, and private repo access depends on plan |
Of the command-line tools here, only TruffleHog tests whether a key is live.
What GitHub secret scanning and push protection miss
GitHub secret scanning checks your whole history on all branches, plus issues, pull requests, discussions and wikis. On public repos it runs automatically for free. For private repos, availability depends on your plan, so check the docs.
It only flags what it recognizes. The supported patterns list has provider patterns, such as AWS, Stripe and Anthropic, plus generic ones like private keys and database connection strings. A Supabase service_role key (a long token format called a JWT) or a random string you made up may match none of them.
Push protection blocks a push that contains a detected secret. The user-level version is on by default on GitHub.com and stops you pushing secrets to public repos. The repository-level version is off by default, needs GitHub Secret Protection, and an admin has to turn it on.
By default, anyone with write access can bypass the repository-level block by giving a reason (delegated bypass can restrict this). Push protection also supports only the newest token versions GitHub can identify with confidence, so older key formats can slip through. It looks at new pushes and does nothing about commits already in your history.
How to remove a leaked key from git history, step by step
Do these in order: step 1 removes the risk, and the remaining steps are cleanup.
Revoke or rotate the key
Create a new key in the provider's dashboard, put it in your host's environment variables, redeploy, then delete the old one. GitHub's docs say that once a key is revoked, rewriting history is often unnecessary. The guide on how to rotate API keys after a breach has the order for common providers.
Check what the old key did
Open the provider's usage or audit logs and look for calls you don't recognize. That tells you whether this was a close call or an incident.
Purge the file with git filter-repo
Work in a fresh clone. GitHub's docs say you need git-filter-repo 2.47 or newer for the
--sensitive-data-removalflag. Prefer it over BFG Repo-Cleaner, which lacks that flag.brew install git-filter-repo git clone https://github.com/YOUR-USERNAME/YOUR-REPO cd YOUR-REPO git-filter-repo --sensitive-data-removal --invert-paths --path .env
Replace
.envwith the full git path of each file your search found, for exampleapps/web/.env, and add one--pathfor each. If the key was hardcoded in source files, use--replace-textwith a file that lists the leaked values.Force-push the rewritten history
Run
git push --force --mirror originto overwrite the remote's history with your rewritten copy. Pushes to GitHub's read-only pull request references (refs/pull/) fail, and that's expected.Deal with the copies you can't reach
Forks, other clones, pull request references and cached views on GitHub can still serve the commit by its hash. Contact GitHub Support to remove cached views and PR references. Their docs say they only help when rotating can't remove the risk, so do step 1 first.
Fork owners have to clean up their own forks. Teammates should re-clone, because a
git pullthengit pushfrom an old clone can bring the key back.Stop the next leak
Add
.gitignorebefore the first commit, install a gitleaks pre-commit hook (a script that scans your changes and blocks the commit if it finds a key), and turn push protection on. Keys that CI needs belong in GitHub Actions secrets, not in the repo.
The gitleaks README gives this pre-commit setup. Run pre-commit autoupdate afterwards to bump the version, then pre-commit install.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaksAnyone can skip the hook with SKIP=gitleaks, so keep push protection on as well. For the rest of your pre-launch list, see the vibe coding security checklist.
Test it yourself
First, see the problem on a throwaway repo:
mkdir scratch && cd scratch && git init echo 'STRIPE_KEY=sk_test_xxx' > .env git add .env && git commit -m 'add env' git rm .env && git commit -m 'remove env' git log -p -S 'sk_test_xxx' --all
Bad result: the last command prints two commits, one that added the string and one that removed it, and the diff shows +STRIPE_KEY=sk_test_xxx. The file is gone from your working tree and still in history.
Now run the searches from above on your real repo. A hit shows a commit hash, a file path and the matching line. No output from git log means that exact string never appeared in the refs you have fetched, so search for the variable name as well as the value. For gitleaks, check echo $? right after the scan: 0 means no findings and 1 means findings.
After a purge, make a fresh git clone and repeat. A good result is zero hits for the old key. Forks and PR refs may still hold it, which is why the key has to be revoked.
Fix prompt you can paste
Give this to Cursor or Claude Code in the repo you want to check. Anything the tool reads or prints goes to your AI provider, so rotate every key it finds:
Audit this repo for leaked secrets, including git history. Never print a secret in full: show the first 4 characters only.
1. Run: git log --all --full-history --oneline -- ':(glob)**/.env' ':(glob)**/.env.*'
2. Run: git log --all --oneline --name-only -G 'sk_live_|service_role|BEGIN (RSA |EC )?PRIVATE KEY|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|sk-ant-|eyJ[A-Za-z0-9_-]{20,}' to list matching commits and files. Do not use -p, so no secret is printed.
3. If gitleaks is installed, run: gitleaks git -v --redact
4. For every hit from step 2, get the line number without printing the line: git show COMMIT:PATH | grep -n -E 'the matching pattern' | cut -d: -f1 (if the file isn't in that commit, try COMMIT^:PATH). Report commit hash, file path, line number and the kind of key. Never print the value.
5. Search the current tree (src/, app/, lib/, supabase/, scripts/, config files) for hardcoded keys, and list every tracked env file from git ls-files.
6. Add .env, .env.* and !.env.example to .gitignore, run git rm --cached on tracked env files, and replace hardcoded keys with process.env reads.
7. Add a .pre-commit-config.yaml with the gitleaks hook.
Do NOT rewrite history or force-push. Finish with a list of every key I must rotate, and explain each change you made.If you want a second opinion on a repo, you can run it through Leakcade.
Further reading
- GitHub docs: Removing sensitive data from a repository
- GitHub docs: About push protection
- GitHub docs: Supported secret scanning patterns
- Gitleaks on GitHub
FAQ
How do I find secrets in my git history?
Run git log -p -S 'your_string' --all to search every branch and tag for a string. Then run gitleaks git or trufflehog git file://. on a fresh clone to catch key formats you didn't think to search for.
How do I remove a leaked key from git history?
Revoke or rotate the key first, because that's what stops misuse. Then, in a fresh clone, run git-filter-repo --sensitive-data-removal --invert-paths --path .env (use the full path your search found) and push with git push --force --mirror origin.
If I delete the file and push, is the key still exposed?
Yes. The commit that added the file still exists in history, in other branches, in forks and in anyone's clone. Treat the key as leaked and rotate it.
Does GitHub secret scanning find every secret?
No. It flags the patterns it supports, and generic or unusual strings may not match. Check the supported patterns list for your provider and run a scanner of your own as well.
Does gitleaks scan the whole git history?
Yes. gitleaks git scans past commits by running git log -p under the hood. Add --redact so keys aren't printed in your terminal.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms