Detecting and Removing Secrets Accidentally Committed to a Public Repo
You pushed a commit, then noticed the .env file was tracked. Or a teammate spotted a hard-coded AWS key in a PR review β after it had already been merged to main. Either way, your first instinct is right: treat this as a live incident, not a cleanup task.
The key point most tutorials miss is that deleting the file and pushing a new commit does not remove the secret. The old commit still exists, is still publicly readable, and is already being crawled by automated scanners that harvest exposed credentials within minutes of a push.
What you'll learn
- How to assess the blast radius immediately after discovering an exposed secret
- How to scan a repository for secrets using open-source tools
- How to rewrite Git history to permanently remove a file or string
- How to force-push safely and handle forks and caches
- How to prevent this from happening again with pre-commit hooks and CI checks
Step 1: Revoke First, Ask Questions Later
Before you touch the repository, rotate or revoke the exposed credential. This is non-negotiable. Automated bots scan GitHub continuously; by the time you read this article, the key may already have been picked up.
- AWS keys: Go to IAM β Security credentials β deactivate or delete the access key, then create a new one.
- GitHub tokens: Settings β Developer settings β Personal access tokens β delete the token.
- Database passwords: Log in to your DB host and change the user's password immediately.
- API keys (Stripe, Twilio, etc.): Use that provider's dashboard to roll the key β most have a one-click rotate button.
Once the credential is dead, an attacker who has it gets nothing. Now you can work on history cleanup without racing against active exploitation.
Step 2: Check Whether GitHub Already Flagged It
GitHub's secret scanning runs on all public repositories automatically. If your repo is public and you pushed a credential from a supported provider (AWS, GCP, GitHub, Stripe, and many others), GitHub may have already sent you an email alert and, in some cases, may have notified the provider directly.
Check Security β Secret scanning alerts in your repo's tab. If an alert is there, acknowledge it after you rotate the credential β this closes the alert and tells GitHub the issue is addressed. Do not close the alert before rotating; the provider integration depends on the alert state in some cases.
Step 3: Scan the Entire Repo for Other Secrets
One exposed secret is a reason to assume there might be others. Run a full scan before you start rewriting history, so you know exactly what you're dealing with.
Using Gitleaks
Gitleaks is the most widely used open-source secret scanner for Git repositories. It inspects every commit in the history, not just the current working tree.
# Install via Homebrew (macOS/Linux)
brew install gitleaks
# Or pull the binary from GitHub releases and add it to PATH
# Scan the entire commit history of the current repo
gitleaks detect --source . --log-opts="HEAD" -vThe --log-opts flag passes arguments to git log under the hood. Using HEAD walks every reachable commit. The output lists each finding with the file, commit hash, line number, and the matched secret pattern.
Using TruffleHog
TruffleHog is a complementary tool that uses both regex patterns and entropy analysis to catch secrets that don't match known patterns.
# Install
pip install truffleHog
# Scan a local repo
trufflehog git file://. --only-verifiedThe --only-verified flag tells TruffleHog to try calling the provider's API to confirm whether the credential is still active. This reduces noise significantly.
Run both tools and combine their output into a list of affected files and commit SHAs. You'll need this list for the next step.
Step 4: Rewrite History With git-filter-repo
The old recommended tool for this was git filter-branch. It works, but it's slow, error-prone, and the Git project itself now recommends against using it. Use git-filter-repo instead β it's faster, safer, and has a much cleaner API.
Install git-filter-repo
# macOS/Linux via pip
pip install git-filter-repo
# Or via Homebrew
brew install git-filter-repoRemove a specific file from all history
If the secret lived in a dedicated file (e.g., secrets.env, config/credentials.json), remove that file entirely from every commit:
git filter-repo --path secrets.env --invert-pathsThe --invert-paths flag means "keep everything except this path". After this command, the file will not exist in any commit in the rewritten history.
Remove a specific string from all history
Sometimes the secret is embedded in a source file that you still need. In that case, replace the literal secret value with a placeholder across all commits:
git filter-repo --replace-text replacements.txtThe replacements.txt file contains one replacement rule per line in the format literal:OLDVALUE==>NEWVALUE:
literal:AKIAIOSFODNN7EXAMPLE==>REDACTED_AWS_KEY
literal:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>REDACTED_AWS_SECRETAfter running this, every commit that contained that exact string will be rewritten with the replacement text instead.
Verify the rewrite worked
# Search all blobs in history for the old value
git log --all --full-history -- secrets.env
# Or use git grep across all commits
git grep "AKIAIOSFODNN7EXAMPLE" $(git rev-list --all)If these return nothing, the secret is gone from your local history. Now you need to push this rewritten history to the remote.
Step 5: Force-Push and Clean Up the Remote
Rewriting history creates new commit SHAs. Your local branch and the remote branch are now diverged, so a normal push will be rejected. You need a force push.
# Push the rewritten main branch
git push origin main --force
# If you have other branches that contained the secret, push those too
git push origin --force --all
# Push rewritten tags if any
git push origin --force --tagsForce-pushing to a shared branch will disrupt any collaborators who have the old history checked out. Coordinate with your team: they should run
git fetch --allfollowed bygit reset --hard origin/mainto align with the rewritten history. Warn them not to push any local branches that were based on the old history.
Ask GitHub to purge cached views
Even after a force push, GitHub caches commit and blob views. A direct URL to the old blob may still be accessible for a short time. Contact GitHub Support and request a cache purge for the specific repository. GitHub's documentation covers this process, and they handle it promptly for security incidents.
Notify fork owners
If your repository has forks, those forks still contain the old history. You cannot rewrite someone else's fork. You can, however, ask fork owners to re-fork from the clean history. For a widely-forked repo this is a bigger problem β which is another reason why rotating the credential immediately (Step 1) is so critical.
Step 6: Audit Your Current Working Tree
Once history is clean, make sure the secret isn't still present in the current file tree. Run Gitleaks in directory mode (not history mode) against the working directory:
gitleaks detect --source . --no-git -vAlso check your .gitignore to confirm the file that held the secret is now properly ignored:
echo ".env" >> .gitignore
echo "config/credentials.json" >> .gitignore
git status # the file should now show as ignored, not trackedCommon Pitfalls
- Thinking a delete commit is enough. It never is. The secret exists in every prior commit. You must rewrite history.
- Forgetting branches and tags.
git filter-reporewrites all reachable commits from HEAD, but you need to explicitly push--alland--tagsto update the remote references. - Skipping the rotation step. History cleanup is secondary. An unrotated credential is still a live vulnerability even after you clean the repo.
- Rewriting a repo with active PRs open. Open pull requests hold references to the old commits. Close or update them after the force push, otherwise the old blobs remain reachable through the PR's diff view.
- Using
git filter-branchinstead ofgit filter-repo. Filter-branch is significantly slower on large repos and has known edge cases with symbolic refs. Stick with filter-repo.
Preventing This From Happening Again
Pre-commit hooks with Gitleaks
Block secrets at commit time, before they ever reach the remote:
# Install pre-commit framework
pip install pre-commit
# Add to .pre-commit-config.yaml
# repos:
# - repo: https://github.com/gitleaks/gitleaks
# rev: v8.18.0
# hooks:
# - id: gitleaks
pre-commit installAfter pre-commit install, every git commit will run Gitleaks on the staged diff. If a secret pattern is matched, the commit is blocked and the finding is printed to the terminal.
Use environment variables, not config files
Store secrets in environment variables and read them at runtime. For local development, use a .env file loaded by a library like python-dotenv or dotenv for Node β and make sure .env is in .gitignore from day one.
import os
from dotenv import load_dotenv
load_dotenv() # reads .env file into environment
db_password = os.environ["DB_PASSWORD"] # raises KeyError if missing, which is intentionalCI secret scanning
Add a secret scanning step to your CI pipeline so that even if a pre-commit hook is bypassed, the CI build fails before merging:
# GitHub Actions example
- name: Scan for secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Wrapping Up
If you're in the middle of an incident right now, here's the condensed action list:
- Rotate or revoke the exposed credential immediately β don't wait until history is clean.
- Check GitHub's Secret scanning alerts under your repo's Security tab.
- Run Gitleaks or TruffleHog over the full history to find all affected commits and files.
- Rewrite history with
git filter-repoto remove the file or replace the secret string, then force-push all branches and tags. - Contact GitHub Support to request a cache purge, and notify fork owners to re-fork from the clean history.
Once the incident is resolved, set up pre-commit hooks and a CI scanning step so future secrets never make it past your local machine. The tools are free and the setup takes less than twenty minutes.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!