Take a breath. This happens to experienced engineers — a tight deadline, a rushed git add ., a "temporary" debug file that wasn't.
Do this first, before anything else: revoke the key. Not clean up Git, not rewrite history, not delete the commit. Revoke.
The reason is simple and worth internalising, because panic pushes people the wrong way: the moment a secret is pushed, you have lost control of every copy. GitHub has it. Anyone who cloned or forked has it. Automated scrapers that watch public commit feeds — and they exist, operating in seconds, not hours — have it. Rewriting history changes what future clones see. It does nothing about the copies already made. A revoked key is worthless to everyone who holds it; a key you carefully scrubbed from history but never revoked is still valid to all of them.
Everything below assumes that order.
Phase 1 — Kill the credential (minutes 0–30)
1. Revoke, then reissue
For most SaaS APIs: vendor dashboard → API keys → revoke the leaked key → create a replacement with the minimum scope needed → update your deployment and local environments.
If revoking immediately would take down production and you're confident the repo was private with a small, trusted collaborator list, you may run a short overlap: issue the new key, deploy it, then revoke. That's a judgement call weighed against exposure. If the repo was ever public, don't make that trade — revoke now and accept the downtime.
Rotation mechanics per provider, including zero-downtime overlap: how to rotate API keys safely.
2. Classify the exposure honestly
- Was the repo public at any point, even briefly? Assume the key is compromised. Not "possibly" — assume it.
- Was it private but with contractors, former employees, or many collaborators?
- Was the commit pushed, or is it still only local? A purely local commit is a much smaller problem — see the shortcut below.
- Did CI run on that commit and write logs or artifacts containing the value?
- Is the repo forked or mirrored anywhere? Forks keep their own copies, and you cannot rewrite someone else's fork.
3. Check the vendor's abuse guidance and logs
Most providers publish an exposed-credential runbook and, more usefully, an audit log. Pull it now while the timeline is fresh: you want to know whether the key was used between the push and the revocation, and from where.
Shortcut: if you have not pushed yet
You're in a much better position — no coordination, no force-push, no collaborators to notify.
# Secret is in the most recent commit, which is not yet pushed:
git rm --cached .env
git commit --amend --no-edit
# Secret is a few commits back, still unpushed:
git rebase -i HEAD~5 # mark the offending commit 'edit', fix, continue
Then verify it's actually gone (see Phase 3) before pushing. Rotate anyway if there's any chance the value reached a log, a build artifact, or a colleague's screen — it's cheap insurance.
Phase 2 — Stop the bleeding in the working tree
# Stop tracking the file but keep your local copy
git rm --cached .env
# Prevent recurrence
cat >> .gitignore <<'EOF'
.env
.env.*
!.env.example
EOF
git add .gitignore
git commit -m "Remove tracked environment file; rotate credentials"
Write a commit message that does not repeat the secret, and don't name the specific key in a public repo — you're advertising exactly which credential to look for in history.
At this point the secret is gone from HEAD but still fully present in history. git revert does not help: it adds a new commit that undoes the change, leaving every old blob intact and readable.
Phase 3 — Confirm what's actually in history
Before rewriting anything, find out precisely where the secret lives. Guessing leads to incomplete rewrites.
# Every commit that ever touched the file, including deletions and renames
git log --all --full-history --oneline -- .env
# Search history for the value itself (use a distinctive prefix, not the whole secret)
git log --all -p -S 'sk_live_' --oneline
# Is the blob still reachable anywhere in the object database?
git grep -I 'sk_live_' $(git rev-list --all) 2>/dev/null | head
That last command is the honest test, and it's what you'll re-run after the rewrite to prove the job is done.
Phase 4 — Purge from history
Only needed if the secret was pushed. GitHub's supported tool is git-filter-repo; the old filter-branch is deprecated and slow enough to be a trap on any real repo.
Official guide, worth reading in full before you run anything: Removing sensitive data from a repository.
brew install git-filter-repo
Option A — remove a whole file from all of history
# Work on a fresh clone. filter-repo rewrites aggressively and is not undoable.
git clone https://github.com/myorg/myrepo.git myrepo-clean
cd myrepo-clean
git filter-repo --invert-paths --path .env
Option B — the file must stay, only the value must go
Useful when the secret was pasted into a config file or a test fixture you still need.
cat > /tmp/replacements.txt <<'EOF'
sk_live_51H8xQwErTyUiOpAsDfGh==>REDACTED
regex:AKIA[0-9A-Z]{16}==>REDACTED
EOF
git filter-repo --replace-text /tmp/replacements.txt
Keep that replacements file outside the repo, and delete it afterwards — it contains the secret in plaintext.
Then verify, re-add the remote, and force-push
git filter-repo deletes the origin remote on purpose, so you cannot force-push a botched rewrite by muscle memory. Verify first:
# Should return nothing at all
git grep -I 'sk_live_' $(git rev-list --all) 2>/dev/null
git remote add origin https://github.com/myorg/myrepo.git
git push --force --all
git push --force --tags
What force-pushing costs you
Be clear-eyed about the coordination burden — this is why revocation comes first:
- Every SHA changes. Existing clones are incompatible. Collaborators must re-clone, or hard-reset to the rewritten branch. Anyone who merges or rebases old history back in reintroduces the blobs.
- Open pull requests break. They reference commits that no longer exist. Expect to reopen them.
- Forks are untouched. You cannot rewrite a fork you don't own. If the repo is public and forked, the secret is still out there. Revocation is your only real remedy.
- GitHub may still serve orphaned commits by SHA for a while after the rewrite. Unreferenced objects can remain accessible until garbage collection. To purge cached views, you need to contact GitHub Support and ask.
That last point is the one that surprises people, and it's the clearest argument for the ordering in this guide: even a textbook-perfect history rewrite leaves a window. A revoked key has no window.
GitHub secret scanning and push protection
If GitHub emailed you an alert, it's because secret scanning matched a known provider pattern. Follow the remediation link, and mark the alert resolved only after the key is actually revoked — not after the history rewrite.
Then turn on push protection for the repo or org. It blocks matching secrets at push time, which is strictly better than finding out afterwards. It's free for public repositories.
Phase 5 — Hunt the copies outside Git
The commit is rarely the only copy. Check:
- CI logs and artifacts. A build that echoed the env, a test that dumped config on failure. Logs often outlive the code and are readable by more people.
- Error trackers. Sentry and similar capture request context and local variables; a secret in an env var can end up in a breadcrumb.
- Container images.
ARG/ENVat build time bakes values into layers, and those layers may be in a public registry:docker history --no-trunc myimage:latest | rg -i 'ARG|ENV' - Time Machine and disk backups. Deleting the file does not remove it from snapshots.
- Tickets, wikis, and Slack where someone pasted "just the error message."
- Your own shell history:
rg -n 'sk_live_|AKIA|ghp_' ~/.zsh_history ~/.bash_history 2>/dev/null
Phase 6 — Make the next one impossible
Technical controls
brew install gitleaks
# Block commits containing secrets, locally, before they exist
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
gitleaks protect --staged --redact --verbose || {
echo "gitleaks: staged changes contain a probable secret. Commit aborted."
exit 1
}
EOF
chmod +x .git/hooks/pre-commit
Full setup, including sharing hooks across a team with core.hooksPath: keep secrets out of Git.
Workflow controls
The deeper fix is that the secret was in your project directory at all. A .env file sitting next to tracked code will eventually be committed by someone in a hurry — that's not a discipline failure, it's a predictable outcome of putting a plaintext secret one git add . away from a repo.
Keeping canonical values in a local vault like PassStore and injecting them at runtime means there is no long-lived plaintext file in the tree to commit. Commit .env.example with blank values so onboarding still works.
Where your stack supports it, short-lived credentials remove the problem class entirely: OIDC in CI, aws sso login locally, GitHub App installation tokens. A credential that expired an hour ago is not an incident.
What if someone actually used the key?
- Revoke everything adjacent. Assume lateral movement — if the key could read other credentials, those are compromised too.
- Pull the vendor audit log and build a timeline: first unauthorised call, source IPs, actions taken.
- Look for persistence. Attackers using a cloud key commonly create new credentials or users so revoking the original changes nothing. Check for IAM users, access keys, OAuth apps, and webhooks you didn't create.
- Escalate per your company's policy. If customer data was reachable, there may be disclosure obligations with legal deadlines — that's a decision for legal and security, not for you alone at midnight.
- Write the timeline down while it's fresh, for the post-incident review.
Realistic abuse scenarios by key type: what happens if someone steals your API key?
Provider quick notes
Vendor UIs change often — always confirm against the current docs.
- npm tokens: revoke under Access Tokens, rotate
NPM_TOKENin CI, then audit published versions for releases you didn't make. (npm docs) - PyPI tokens: revoke in Account settings → API tokens, then update Twine, CI, and
.pypirc. (PyPI help) - Slack / Discord bot tokens: revoke in the app dashboard; with broad scopes, assume workspace content was readable. (Slack token types)
- AWS
AKIA…: set the keyInactiveimmediately, checkaws iam get-access-key-last-usedand CloudTrail for first unauthorised use, then move to OIDC so there's no static key next time. - Stripe: roll the key from the dashboard; treat a leaked
sk_live_as an active financial incident and check recent charges and refunds.