Remediation guide
Accidentally committed an API key to GitHub — what to do right now
You pushed a commit and spotted a secret in the diff — or GitHub Secret Scanning just fired an alert. Either way, the window for acting is already short. Automated harvesters maintain persistent connections to the GitHub Events API, which exposes every public push in real time, and researchers have documented credential collection within 60 seconds of a new commit landing. Here is the exact sequence of steps, in the order they matter.
- How fast bots find leaked keys
- Step 1: Rotate the key
- Step 2: Audit what else leaked
- Step 3: Remove from codebase
- Step 4: Scrub git history
- Step 5: Check GitHub's alerts
- Prevention
- FAQ
How fast can bots find a leaked key in a GitHub commit?
Within seconds to a few minutes for public repositories. The GitHub Events API streams every push to any unauthenticated consumer in near real time, and dedicated credential harvesters — run by both security researchers and malicious actors — maintain persistent connections to it around the clock. One widely-cited 2019 study found that valid AWS keys started receiving unauthorized requests within 0.5–4 minutes of being pushed. The landscape has only gotten more aggressive since.
Private repositories are meaningfully safer but not safe: anyone who has already cloned the repo holds a local copy of those commits, and a repository that was public for even a brief moment may have been captured. The correct response is the same regardless of visibility: revoke the key and assume it has been collected.
Step 1: Revoke or rotate the key immediately
Go to the issuing service's dashboard and revoke or regenerate the exposed credential before doing anything else. Deleting the file from the repo does not invalidate a key that has already been scraped; only the issuing service can do that.
AWS
IAM console → Users → Security credentials → delete the compromised access key, create a new one. If it was a root account key, revoke it immediately and create a scoped IAM user instead.
OpenAI / Anthropic
Platform dashboard → API Keys → delete the old key, generate a replacement. The old key is invalidated at the moment of deletion.
Stripe
Dashboard → Developers → API keys → Roll the secret key (the sk_live_ or sk_test_ value). Rolling invalidates the old key and issues a new one.
GitHub tokens
Settings → Developer settings → Personal access tokens → Revoke, then create a new token with only the scopes actually needed.
Supabase
Project Settings → API → Reset the service_role key. The anon key is designed to be public, but reset it too if it was in the same file.
Twilio / SendGrid
Twilio Console → API Keys → revoke. SendGrid → Settings → API Keys → delete. Create replacements with narrower scopes.
Google Cloud
APIs & Services → Credentials → delete the API key, create a new one. If it's an OAuth client secret, regenerate it under the same OAuth client.
Database URLs with passwords
Reset the password for the database user in the connection string (ALTER USER youruser WITH PASSWORD 'new_pw'; for Postgres). Update all references: CI secrets, other services, other env files.
Step 2: Audit what else may have leaked
While you have the affected file open, check whether other secrets were in the same commit or in nearby files that were part of the same push. It is common to find a cluster of credentials in a single .env or config file — fixing one and missing the others leaves you partially exposed.
Paste the affected file or the entire commit diff into LeakCheck. It runs 40+ credential patterns — AWS, Stripe, OpenAI, Anthropic, GitHub, Twilio, Google, database URLs, JWTs, PEM blocks, and more — entirely in your browser. Your code is never uploaded.
Check your code with LeakCheck →
Step 3: Remove the key from your codebase
With the key rotated and all related secrets audited, remove the raw value from every source file it appears in. Replace hardcoded values with environment-variable references:
# Before (hardcoded — do not do this)
const client = new Stripe("sk_live_ACTUALKEY");
# After (reads from environment at runtime)
const client = new Stripe(process.env.STRIPE_SECRET_KEY);
At the same time, ensure the file that contained the secret is in .gitignore:
# Add to .gitignore
.env
.env.local
.env.*.local
config/secrets.yml
*.pem
Verify git is actually honoring the ignore rule: run git status. If the file still appears under "Changes not staged for commit," it is already tracked — run git rm --cached .env to un-track it before adding it to .gitignore.
Commit and push this change. That removes the secret from the tip of the branch — but not from history. That is the next step.
Step 4: Scrub the secret from git history
Deleting a line and committing the change removes the secret from the current working tree, but leaves it intact in every historical commit that ever contained it. Anyone with read access to the repo can run git checkout <old-sha> -- .env and read the original value. To actually expunge it, you need to rewrite the repository's commit graph.
Option A: git-filter-repo (recommended)
git filter-repo is the Git-project-endorsed replacement for the deprecated git filter-branch. It is significantly faster and safer.
# Install (requires Python 3)
pip install git-filter-repo
# Remove a specific file from every commit in every branch
git filter-repo --path .env --invert-paths --force
# Or replace a specific secret string value with a placeholder
# (useful when the secret was inline in a source file, not a standalone file)
printf 'sk_live_YOURKEY==>***REMOVED***\n' > /tmp/replacements.txt
git filter-repo --replace-text /tmp/replacements.txt --force
# Force-push the rewritten history
git push origin --force --all
git push origin --force --tags
Option B: BFG Repo-Cleaner (Java)
BFG is faster than filter-branch for large repositories and requires only a JVM rather than Python. It operates on a bare clone.
# Download bfg.jar from https://rtyley.github.io/bfg-repo-cleaner/
# Clone a fresh bare copy of your repo
git clone --mirror git@github.com:yourorg/yourrepo.git
# Remove a file from all history
java -jar bfg.jar --delete-files .env yourrepo.git
# Or delete all text matching a secret value
java -jar bfg.jar --replace-text replacements.txt yourrepo.git
# Clean up and force-push
cd yourrepo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
Step 5: Check GitHub's Secret Scanning alerts
GitHub scans all public repositories for known credential patterns and alerts both the repository owner and — through its Partner Program — the affected service provider. Some providers automatically invalidate keys GitHub notifies them about, but this is not guaranteed and is not a substitute for manual revocation.
Check your repository's Security tab for any open alerts:
Your repo → Security tab → Secret scanning alerts
For private repositories, secret scanning is available on GitHub Advanced Security (free for public repos, paid for private). If you are not on a paid plan and the repo is private, GitHub will not have scanned it — rely on manual rotation and LeakCheck instead.
Even if GitHub auto-revoked the key through a partner integration, verify the key is inactive in the service provider's own dashboard, and check for any unexpected usage or charges that may have occurred in the window before revocation.
Prevention: stop this from happening next time
- Add
.env*to.gitignorebefore your first commit. Many framework starters include this already — verify it is there. Confirm withgit check-ignore -v .env; if it prints a match, you are covered. - Use environment variables from your hosting platform, not files. Every modern host — Netlify, Vercel, Railway, Render, Fly.io, Heroku — has a secrets UI. Your code reads
process.env.MY_KEYat runtime; the value is injected by the platform and never written to a file that could end up in the repository. - Scan before you commit, not after. Paste your staged diff or entire
.envinto LeakCheck before pushing. For automated coverage, integrate a CLI scanner such asgitleaksas a pre-commit hook so no secret can reach the remote even by mistake. - Limit token scopes to the minimum you actually need. If a narrow-scope key leaks, the blast radius is smaller: a read-only token cannot make charges; a single-repository token cannot access your other repositories.
- Rotate secrets on a schedule, not just on incidents. Regular rotation reduces the value of any key that was harvested at some point without your knowledge.
- Check your live site, not just your source. If your build or deploy process accidentally copies config files to the public directory, secrets can end up served over HTTP. ExposureCheck probes your deployed URL for exposed
.env,.gitdirectories, source maps, and bundled secrets.
Frequently asked questions
Does removing a file from GitHub delete it from the commit history?
No. Deleting a file creates a new commit that removes it from the current working tree, but every previous commit that ever contained the file still does. The full history is preserved and any user with read access can check out an old commit and read the file. To actually remove it from history, use git filter-repo or BFG and force-push (see Step 4).
How quickly do bots find a leaked API key in a GitHub commit?
Typically within seconds to a few minutes for public repositories. The GitHub Events API is a public, real-time stream of all push events, and dedicated tools continuously monitor it. A 2019 research paper found unauthorized AWS API calls beginning within 0.5–4 minutes of keys being pushed. Assume any public commit is scanned immediately.
What if the repository was private the whole time?
Private repositories are not monitored by the public Events API, so automated harvesters are very unlikely to have captured the key. However, all collaborators who have cloned the repository already hold a local copy of those commits, and any collaborator who was removed still has their clone. Rotate the key and scrub history anyway — it is 30 minutes of work versus an unknown amount of ongoing risk.
GitHub said it auto-revoked my key through the Partner Program — am I safe?
GitHub's partner integrations revoke secrets automatically for several major providers (AWS, Stripe, GitHub itself, Twilio, and others). Even so, confirm the revocation in the provider's own dashboard — the partner notification can take a few minutes, and there may be a window of exposure. Also check for unexpected API usage, new IAM users, or charges in that window.
I pushed to a fork, not the original repo. Does that change anything?
A fork has its own full copy of git history. If the fork is public, the same automated harvesting risk applies. Clean the fork's history with git filter-repo or BFG and force-push it independently of the upstream repository.
Can I just make the repository private to fix this?
Making a repository private stops future automated scanning of new commits, but it does not remove the secret from your history, does not invalidate the key, and does not prevent access by anyone who cloned before the visibility change. Rotation is still required, and history cleanup is still strongly recommended.
Also in the Copper Bay Labs ship-safety suite
An accidental secret commit is one of several things to catch before and after shipping. These free tools cover the rest:
Paste code, .env, or a config diff and see every exposed key flagged by severity before it reaches the repo.
Scan your live URL for exposed .env files, .git directories, source maps, and bundled secrets. Free, no signup.
Check your security headers and Content Security Policy so your site does not become an exfiltration vector after secrets are cleaned up.