Secrets
How to Find Hardcoded Secrets in a GitHub Repository
By the BugSafer Security Team · · 6 min read
Hardcoded secrets are credentials committed directly into a repository: API keys, database passwords, private keys, tokens, and connection strings. To find them, you search the current files, the configuration and CI files people forget, and the full git history, because a secret deleted in a later commit is still readable in the commit that introduced it.
This article walks through where secrets actually hide, why plain grep and regex only get you part of the way, how to scan history properly, and what to do the moment you find something. The short version of the last part: rotate the credential first, then clean up.
Where secrets hide in a repository
Most people search the source tree and stop. That misses the places secrets most often leak into.
Application code. Constants like const API_KEY = "...", default credentials in config objects, and test fixtures that use real keys instead of fakes.
Environment and config files. .env, .env.local, config.json, settings.py, application.properties, docker-compose.yml, and Kubernetes manifests. A committed .env is one of the most common causes of a leak, usually because someone ran git add . before the .gitignore was in place.
CI/CD and infrastructure. Workflows under .github/workflows/, .gitlab-ci.yml, Jenkinsfiles, Terraform files, and Ansible playbooks. These often contain tokens for deployment or cloud access.
Documentation and notebooks. README examples, Jupyter notebooks (which embed outputs, including printed tokens), and Postman collections.
Git history. Every commit, every branch, every tag. A secret removed in commit 50 is still present in commit 12. This is the part that trips people up, and it is the reason "I deleted it" is not a fix.
Non-code artifacts. Compiled bundles, .zip files, database dumps, and log files checked into the repo.
Start with targeted search, not blind grep
A quick pass over the working tree is worth doing, but do it with intent. Search for the shapes real credentials take rather than the word "password."
# Common high-signal patterns (run from the repo root)
grep -rInE '(api[_-]?key|secret|token|passwd|password|private[_-]?key)\s*[:=]' \
--include='*.py' --include='*.js' --include='*.ts' --include='*.env*' .
# Private key blocks
grep -rIn "BEGIN .*PRIVATE KEY" .
# AWS-style access key IDs
grep -rInE 'AKIA[0-9A-Z]{16}' .
You can also use git grep, which respects .gitignore and is faster on large trees:
git grep -nIE 'AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----'
Why grep and regex are not enough
Regex catches secrets that look like secrets. It misses:
- High-entropy strings with no recognizable prefix, like a random 40-character token assigned to a variable named
x. - Secrets split across lines or built by concatenation.
- Credentials in binary files, archives, and notebooks.
- Anything in history unless you explicitly search it.
It also produces false positives: hashes, UUIDs, base64-encoded images, and test placeholders all match naive patterns. Expect to triage results by hand. Regex is a fast first filter, not a complete audit.
Scan the full git history
History is where the real risk lives, because a leaked secret stays valid until someone rotates it, regardless of whether it is in the current tree.
The simplest approach is to search every commit's diff:
# Search all commits for a pattern, showing the commit and file
git log -p --all -S 'AKIA' --oneline
# Or grep the content of every blob reachable from any ref
git rev-list --all --objects | \
git cat-file --batch-check='%(objecttype) %(objectname) %(rest)' | \
awk '$1=="blob"{print $2}' | \
git cat-file --batch | grep -aE 'AKIA[0-9A-Z]{16}'
For anything beyond a quick check, use a dedicated scanner. These tools understand entropy, known token formats, and history traversal, and they cut false positives substantially:
- gitleaks scans working trees and git history and is easy to run in CI.
- trufflehog scans git, filesystems, and other sources, and can verify some credentials against the provider.
- detect-secrets (from Yelp) is designed for pre-commit hooks and baseline files.
A typical gitleaks run over history:
gitleaks detect --source . --log-opts="--all" --report-format json --report-path leaks.json
Run the scanner against the full history (--all), not just the default branch, and include tags. Secrets sometimes live only on an old feature branch that was never merged but was pushed.
Scanning in CI so it does not happen again
Add a scan to your pipeline and to a pre-commit hook so new secrets are caught before they land:
# .github/workflows/secret-scan.yml
name: secret-scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history, not a shallow clone
- name: Run gitleaks
run: gitleaks detect --source . --log-opts="--all" --redact
fetch-depth: 0 matters. A shallow clone hides history, and the scan will silently miss old commits.
What to do after you find a secret
The order of operations is not negotiable: rotate first, clean up second.
1. Treat the credential as compromised
Assume anyone could have read it. Public repositories are continuously scraped, and even private repos can be exposed through forks, clones, CI logs, and former collaborators. Do not spend time deciding whether it "really" leaked.
2. Rotate or revoke the credential
Go to the provider (cloud console, database, third-party API) and revoke the old key and issue a new one. Update the secret in your secret manager or CI secret store, not in the repo. If the credential grants broad access, review what it could reach and check provider audit logs for unexpected use.
3. Remove it from the code and history
Replace the hardcoded value with an environment variable or secret manager lookup:
# Vulnerable
STRIPE_KEY = "sk_live_51H8x..."
# Fixed: read from the environment, fail loudly if missing
import os
STRIPE_KEY = os.environ["STRIPE_KEY"]
Then purge it from history. Rewriting history is disruptive, so coordinate with your team:
# Example with git-filter-repo (preferred over the older filter-branch)
git filter-repo --path .env --invert-paths
Everyone with a clone must re-clone or reset, and any open pull requests need rebasing. If the repo is public and widely forked, history rewriting cannot reach the forks, which is exactly why rotation comes first.
4. Prevent the next one
Add the file to .gitignore, commit a .env.example with placeholder values, enable GitHub secret scanning and push protection if your plan supports it, and turn on branch protection so secrets cannot be merged without review.
Common pitfalls
- Deleting the file in a new commit. The secret is still in history. Rotation is the only real fix.
- Assuming private means safe. Private repos still have many readers and copies.
- Trusting a clean current tree. The working directory can be spotless while history is full of keys.
- Shallow clones in CI. Scanners see only recent commits and report nothing.
- Ignoring false positives forever. Triage them and add allowlists so real findings stand out.
- Forgetting encoded secrets. Base64-encoded values and secrets inside notebooks or archives evade simple text search.
Checklist
- Search the working tree for credential-shaped strings with
git grepand targeted regex. - Check
.env, config files, CI workflows, IaC, notebooks, and docs, not just source code. - Scan the full git history across all branches and tags with a dedicated tool like gitleaks or trufflehog.
- Run the scanner in CI with full history (
fetch-depth: 0) and in a pre-commit hook. - On finding a secret, rotate or revoke it before doing anything else.
- Replace hardcoded values with environment variables or a secret manager.
- Purge the secret from history with
git filter-repo, then coordinate re-clones with your team. - Add
.gitignoreentries, a.env.example, secret scanning push protection, and branch protection.
FAQ
Can I just delete the file and commit again?
No. Deleting the file in a new commit leaves the secret intact in every earlier commit, and anyone can check out that commit or read it through the GitHub UI. You must rotate the credential, because it should be considered exposed the moment it was pushed. Removing it from history is cleanup, not remediation.
Does GitHub secret scanning find everything?
GitHub's secret scanning detects many known token formats from supported providers and can block pushes with push protection, but it does not catch every secret type, custom internal tokens, or arbitrary high-entropy strings. Treat it as one layer. Pair it with a history-aware scanner like gitleaks or trufflehog and a pre-commit hook.
How do I scan a repository I just cloned?
Clone with full history (avoid --depth 1), then run a scanner over all refs, for example gitleaks detect --source . --log-opts="--all". If you only need a quick check, git log -p --all -S '<pattern>' searches every commit's diff for a string. For thorough coverage, use a dedicated tool rather than hand-written regex.
Check your repositories for leaked secrets
BugSafer scans your GitHub repositories for committed API keys, tokens and private keys, and reports each one with masked evidence so you can rotate it.