I Pushed a Secret to a Public Repo

An API key or password landed in a public GitHub repo. Rotate the key first, then purge it from history with git-filter-repo before a scraper uses it.

You ran git push origin main, then noticed the commit included config.py with STRIPE_SECRET_KEY = "sk_live_abc123...". Or GitHub emailed you: Secret scanning detected a Stripe API Key in commit abc1234. Public repos are scraped by bots within seconds of a push, so assume the key is already compromised.

Fastest fix, in this exact order — do not reorder:

  1. Rotate (revoke + reissue) the leaked credential at the provider. Do this first. History cleanup is worthless if the live key is still valid.
  2. Make the repo private while you clean (temporary, blocks new scrapers).
  3. Purge the secret from all history with git filter-repo --sensitive-data-removal, then git push --force --mirror origin.
  4. Audit with TruffleHog and turn on push protection so it cannot happen again.

Removing the file without rotating is the most common mistake. Scrapers already grabbed the value; only rotation makes it useless.

Which bucket are you in?

SituationIs the live key still dangerous?Minimum action
Pushed seconds ago, repo publicYes, assume harvestedRotate now, then purge history
GitHub secret-scanning alert firedYes — GitHub notifies the provider but does not rotate it for youRotate now, confirm on provider dashboard
Repo was public, now privateYes — anyone who cloned still has itRotate now, purge history
Repo always private, internal onlyLower, but anyone with read access has itRotate if any doubt, then purge
Secret is a placeholder / fake fixtureNoNo rotation; just stop the scan alert by using an obviously fake value

As of June 2026, GitHub secret scanning runs free and always-on for public repos. For provider tokens in its partner program (Stripe, AWS, GitHub PAT, OpenAI, Slack, Supabase, Vercel, and 100+ more), GitHub forwards the match to the issuing provider, who may revoke or flag it. GitHub itself never rotates your credential, so never skip step 1.

Common causes

Ordered by hit rate, highest first.

1. Hardcoded credentials in config files committed directly

A real API key was added to config.py, .env, or application.properties for local testing and committed without checking the contents.

How to spot it: git show HEAD -- config.py | grep -i "key\|secret\|password\|token" returns the credential directly in the diff.

2. .env file not in .gitignore

A .env was created for local development but the repo had no .gitignore, or .env was not listed in it, so git add . swept it in. Adding .gitignore later does not untrack a file Git is already tracking.

How to spot it: git ls-files | grep "\.env" returns the file even though you expect it ignored. grep "\.env" .gitignore returns nothing.

3. Secret in a test fixture or seed data file

A developer replaced a placeholder with a real key to make a test pass and committed the fixture.

How to spot it: git grep -n "sk_live\|ghp_\|AKIA\|AIza\|SG\.\|xoxb-" -- "*.json" "*.yaml" "tests/" finds live credential patterns in test directories.

4. Credential in a notebook output cell

Jupyter notebooks (.ipynb) store output cells as JSON inside the file. A secret printed during a run gets serialized into the committed notebook.

How to spot it: git show HEAD -- notebook.ipynb | grep -iE "sk_live|ghp_|AKIA|AIza" finds the value inside the serialized output.

5. Secret embedded in a binary or compiled artifact

A compiled binary committed to the repo has the secret baked in.

How to spot it: git show HEAD --stat | grep -E "\.so|\.exe|\.dll|\.wasm" lists committed binaries; run strings binary_file | grep sk_live on each.

Shortest path to fix

Step 1: Rotate the exposed secret immediately — before anything else

Go directly to the provider and create a new credential, then deactivate the old one:

Stripe:     dashboard.stripe.com > Developers > API keys > Roll key
GitHub PAT: Settings > Developer Settings > Personal access tokens > Delete (then regenerate)
AWS:        IAM > Users > Security credentials > Create access key, then Deactivate old
OpenAI:     platform.openai.com > API keys > Revoke (then Create new secret key)
Twilio/SendGrid/etc: equivalent revocation page
Database:   change the password now

The old key is now dead. Proceed with cleanup while it cannot be used.

Step 2: Make the repo private (temporary, not a fix)

GitHub: Settings > General > Danger Zone > Change repository visibility > Make private

This stops new scrapers while you rewrite history. Re-publish after cleanup if you want.

Step 3: If you pushed seconds ago and nobody has cloned, amend

Only safe if the bad commit is the tip and the repo is essentially solo:

# Remove the secret from the file first, then:
git add config.py
git commit --amend --no-edit
git push --force-with-lease origin main

If anyone may have cloned or forked, skip this and go to Step 4 — amending only rewrites the latest commit, and the secret survives in older commits or other clones.

Step 4: Remove the secret from all history with git-filter-repo

git filter-repo is the tool GitHub officially recommends (it replaced the slow, deprecated git filter-branch). Install it first:

brew install git-filter-repo     # macOS
pip install git-filter-repo      # cross-platform

Then either replace the secret string everywhere, or drop the whole file. The --sensitive-data-removal flag is GitHub’s documented invocation for this case:

# Option A — replace the literal secret with a placeholder in every commit.
# Create expressions.txt with one rule per line:
#   sk_live_abc123...==>***REMOVED***
git filter-repo --sensitive-data-removal --replace-text expressions.txt

# Option B — remove the entire offending file from all history
git filter-repo --sensitive-data-removal --invert-paths --path config.py

git filter-repo refuses to run on a repo with uncommitted changes or, by default, one that is not a fresh clone — pass --force if you accept the rewrite. It also removes your origin remote on purpose, so you re-add it next.

Step 5: Force-push the rewritten history to every branch and tag

git remote add origin <url>          # filter-repo dropped the remote
git push --force --mirror origin     # GitHub's recommended one-shot: all branches + tags

If --mirror is too blunt for your remote (it deletes remote refs you do not have locally), push explicitly instead:

git push --force-with-lease --all
git push --force-with-lease --tags

Step 6: Clear local objects, then audit and lock it down

# Expire reflogs and garbage-collect the old objects locally
git reflog expire --expire=now --all
git gc --prune=now --aggressive

# Audit the working tree and full history for any other live secrets.
# Use the git sub-command for history (file:// is required for a local path):
npx trufflehog filesystem . --only-verified
npx trufflehog git file://"$(pwd)" --only-verified

--only-verified keeps only secrets TruffleHog confirmed are still active, so you are not chasing already-rotated keys.

Step 7: Cached views and forks (the part people forget)

  • Cached commit views: GitHub may still serve the old commit by its SHA at a URL for a while after the rewrite. Open a request at support.github.com asking them to purge cached views, saying the secret was pushed and history has been rewritten. Per GitHub’s policy, Support only removes data when the risk cannot be mitigated by rotating the credential — which is exactly why Step 1 comes first.
  • Forks: a fork is an independent repo. Your force-push does not touch it, and force-pushing cannot reach forks you do not own. The leaked commit stays reachable in any fork until that owner reclones or deletes it. Rotation is what actually neutralizes this.

How to confirm it’s fixed

  1. git log --all --oneline -S "sk_live_abc123" (or the file: git log --all --oneline -- config.py) returns nothing — the string and/or file are gone from every branch and tag.
  2. On GitHub, open the old commit by its SHA; you should get a 404 (allow a few minutes, and request a cache purge if it lingers).
  3. npx trufflehog git file://"$(pwd)" --only-verified reports zero verified secrets.
  4. On the provider dashboard, the old key shows revoked/deleted and a new key is in use by your app/CI.
  5. In the GitHub Security tab, the secret-scanning alert is closed as Revoked.

Prevention

  • Add .env to .gitignore before the first commit: echo ".env" >> .gitignore && git add .gitignore. If it is already tracked, git rm --cached .env then commit.
  • Commit only a .env.example with placeholder values; document that developers copy it to .env locally.
  • Turn on GitHub push protection: Settings > Security > Advanced Security > Secret protection. As of June 2026 it is free for all repos including public ones, and blocks a push that contains a recognized secret pattern before it reaches the remote. (Detection is always on for public repos; the repo-level block is off until an admin enables it.)
  • Install a pre-commit scanner — detect-secrets or gitleaks — so secrets are caught before they are ever committed.
  • For test fixtures, use obviously fake values (sk_test_placeholder), not real-looking keys.
  • Inject secrets at runtime from CI/CD secrets, a secret manager (AWS Secrets Manager, Doppler, HashiCorp Vault), or Kubernetes Secrets — never from a committed file.
  • Rotate credentials on a schedule so any key that leaked in old history is already invalid.

FAQ

Q: I deleted the file and committed again — am I safe now? A: No. Deleting the file only adds a new “delete” commit; the original commit still holds the plaintext and git show <old-sha> shows it in full. You must rewrite history with git filter-repo (Step 4) to truly remove it — and you still must rotate the key.

Q: GitHub’s secret scanning says it found and “reported” the secret. Did GitHub revoke it? A: No. For partner token types, GitHub notifies the issuing provider, and the provider decides whether to revoke. GitHub never rotates your credential. Confirm on the provider dashboard and issue a new key yourself.

Q: The repo is private now. Am I safe? A: Safer, not safe. Anyone who cloned or forked while it was public still has the key, and bots may have scraped it in the seconds it was exposed. Treat any leaked secret as compromised regardless of current visibility.

Q: Can I use BFG Repo-Cleaner instead? A: Yes. Put old_secret==>***REMOVED*** in a replacements.txt and run bfg --replace-text replacements.txt, then git reflog expire --expire=now --all && git gc --prune=now --aggressive. BFG is fast for big repos, but git filter-repo handles more edge cases and is GitHub’s recommended tool.

Q: We have 50 forks. Does force-pushing update them? A: No. Forks are independent repos; your push cannot rewrite them. Ask each fork owner to reclone, or request fork removal from GitHub Support. Either way, rotation makes the leaked key useless, which is the real fix.

Q: Will collaborators’ branches reintroduce the secret after I rewrite history? A: They can. A teammate who branched off the tainted history must rebase their work onto the cleaned history, not merge — one merge commit can drag the old objects back. Have everyone reclone or rebase after your force-push.

Tags: #git #version-control #Troubleshooting