I Pushed a Secret to a Public Repo

An API key or password landed in a public GitHub repo. Rotate the secret immediately, then purge it from history before anyone uses it.

You just ran git push origin main and immediately noticed the commit included config.py containing STRIPE_SECRET_KEY = "sk_live_abc123...". Or GitHub’s secret scanning bot sent you an email: “We found a potential secret in your push.” The 30-second window between push and rotation is long enough for automated scrapers to harvest the key. GitHub’s Secret Scanning may revoke the key automatically for some providers, but you cannot rely on that. The right response is fast, sequential, and non-negotiable: rotate first, remove from history second, audit third. Removing from history without rotating is useless because scrapers already have the key.

Common causes

Ordered by hit rate, highest first.

1. Hardcoded credentials in config files committed directly

Developer added a real API key to config.py, .env, or application.properties to test locally and committed the file without checking its 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 file was created for local development but the repo had no .gitignore, or .env was not listed in .gitignore. git add . swept it in.

How to spot it: cat .gitignore | grep ".env" returns nothing. git ls-files | grep ".env" returns the file.

3. Secret in a test fixture or seed data file

Unit tests often include realistic-looking API keys as fixtures. 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\." -- "*.json" "*.yaml" "tests/" finds live credential patterns in test directories.

4. Credential in a notebook or compiled output

Jupyter notebooks (.ipynb) can store output cells containing secret values printed during execution. The output is serialized as JSON inside the notebook file.

How to spot it: git show HEAD -- notebook.ipynb | python -c "import sys,json; d=json.load(sys.stdin); [print(o) for c in d['cells'] for o in c.get('outputs',[])]"

5. Secret embedded in a binary or compiled artifact committed to the repo

A compiled binary that was committed to the repo has the secret baked in. strings binary_file | grep sk_live reveals it.

How to spot it: git show HEAD --stat | grep "\.so\|\.exe\|\.dll\|\.wasm" — any committed binary may contain embedded secrets.

Shortest path to fix

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

Do not wait until history is clean. Go directly to the provider:

Stripe: dashboard.stripe.com > Developers > API keys > Roll key
GitHub PAT: Settings > Developer Settings > Personal Access Tokens > Delete
AWS: IAM > Security credentials > Create new access key, then deactivate old
Twilio, SendGrid, etc: equivalent revocation page

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

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

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

This blocks new scrapers while you clean history. Re-make it public after cleanup.

Step 3: Remove the secret from the latest commit if not yet pushed widely

If the push was moments ago and you want to amend before many people cloned:

# Edit the file to remove the secret
git add config.py
git commit --amend --no-edit
git push --force-with-lease origin main

This only works if the secret has not spread to forks or other clones.

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

pip install git-filter-repo

# Replace the literal secret with a placeholder everywhere in history
git filter-repo --replace-text <(echo "sk_live_abc123...**REMOVED**")

# Or remove the entire file from history
git filter-repo --path config.py --invert-paths --force

Step 5: Force-push all branches and tags

git remote add origin <url>   # filter-repo removes the remote
git push --force-with-lease --all
git push --force-with-lease --tags

Step 6: Audit for additional secrets and prevent recurrence

# Scan the current working tree
npx trufflesecurity/trufflehog filesystem . --only-verified

# Scan the full git history
trufflehog git file://. --only-verified

Prevention

  • Never commit .env — add it to .gitignore before the first commit: echo ".env" >> .gitignore && git add .gitignore.
  • Enable GitHub Advanced Security secret scanning and push protection to block pushes that contain known secret patterns before they reach the remote.
  • Use environment variables injected at runtime (CI/CD secrets, Kubernetes Secrets, AWS Secrets Manager) rather than config files in the repo.
  • Install git-secrets or detect-secrets as a pre-commit hook so secrets are caught before they are even committed locally.
  • For test fixtures, use obviously fake placeholder values (sk_test_placeholder, not a real-looking key) or use mocking frameworks.
  • Store only a .env.example file with placeholder values in the repo; document that developers copy it to .env locally.
  • Rotate credentials on a schedule (every 90 days) so leaked historical keys are already invalid.

FAQ

Q: The secret is in history but the repo is private. Am I safe? A: Safer, but not safe. Anyone with past read access already cloned it. If the repo was ever public, even briefly, scrapers may have it. Treat every leaked secret as compromised regardless of current repo visibility.

Q: GitHub secret scanning says it found a secret and auto-revoked it. Do I still need to rotate? A: Yes. Auto-revocation only applies to supported providers (GitHub PAT, Stripe, AWS, etc.) and only if GitHub’s scan fired within seconds. Confirm revocation on the provider’s dashboard and issue a new credential anyway.

Q: Can I use BFG Repo Cleaner to replace the secret text? A: Yes: create a replacements.txt file with old_secret==>**REMOVED** and run bfg --replace-text replacements.txt. However, git filter-repo handles edge cases more reliably.

Q: We have 50 forks of the repo. Will force-pushing update them? A: No. Forks are independent repos. You must contact each fork owner and ask them to reclone. GitHub has an option to delete all forks when you delete the repo, but re-creating from scratch is a last resort. Rotation makes the leaked key useless regardless.

Tags: #git #version-control #Troubleshooting