A developer at a fintech startup pushed an AWS root key to a public repo at 2:14 AM on a Tuesday. By 2:17 AM, someone in Southeast Asia had spun up 340 EC2 instances for crypto mining. The bill hit $46,000 before anyone woke up.
The developer deleted the key from the file and pushed again. Problem solved, right?
No. The key was still in git history. And the bots had already grabbed it anyway.
Git Remembers Everything You Wish It Wouldn't
This trips up even experienced developers. Git is an append-only log. When you commit a file containing STRIPE_SECRET_KEY=sk_live_abc123, that string gets stored as a blob object in .git/objects. Deleting the line and committing again creates a new blob. The old one stays. Forever. Or until someone rewrites history, which almost nobody does correctly.
# Want to see every secret ever committed? Easy.
git log --all -p | grep -E "(password|secret|api_key|token)" --color
# Or use truffleHog, which is way more thorough
trufflehog git file://. --only-verified
TruffleHog scans entropy patterns and known credential formats across your entire commit history. Run it on a repo that's been around for two years. You'll find things that make you uncomfortable.
The Three-Minute Window
GitHub themselves published research on this. When a secret gets pushed to a public repository, automated scrapers detect it within minutes. Not hours. Minutes. There are botnets specifically designed to monitor the GitHub Events API, which broadcasts every push event in near real-time.
So the workflow a lot of teams rely on — push, realize the mistake, delete, force push — that workflow is fundamentally broken. By the time you notice, someone already has your credential. GitGuardian reports that over 10 million secrets were detected in public commits in 2023 alone. And those are just the ones their scanner caught.
The private repos aren't safe either. A compromised developer laptop, a misconfigured CI runner, a read token with too much scope — any of these can expose your git history to someone who shouldn't have it.
Why .env Files Aren't a Strategy
.env files are fine for local development. That's it. They're not secrets management.
Problems start when teams treat .env as the canonical source of truth for production secrets. Someone copies the file to a server over SSH. Someone else commits .env.production because the deployment script needs it. A third person puts secrets in docker-compose.yml environment variables and pushes that to the repo because "it's a private repo."
# docker-compose.yml committed to repo
# "It's fine, the repo is private" — famous last words
services:
api:
environment:
- DATABASE_URL=postgres://admin:Pr0d_p@ssw0rd!@db.internal:5432/main
- STRIPE_KEY=sk_live_actual_production_key
- JWT_SECRET=our-company-jwt-secret-2024
That docker-compose.yml is now in every developer's local clone. On their personal laptops. Possibly on machines with no disk encryption. Definitely on machines where they install random npm packages from the internet.
What Actually Works
Pre-commit hooks that block secrets before they enter history
This is the cheapest win. Tools like detect-secrets from Yelp or gitleaks run as pre-commit hooks and refuse to let you commit files containing patterns that look like credentials.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# Or if you want something lighter:
# pip install detect-secrets
# detect-secrets scan > .secrets.baseline
# detect-secrets-hook --baseline .secrets.baseline
But here's the catch: pre-commit hooks are local. They run on the developer's machine. Which means a developer can skip them with --no-verify, and new team members might not have them installed at all. You need server-side protection too.
GitHub push protection and GitGuardian
GitHub's push protection (available on public repos and GitHub Advanced Security) blocks pushes that contain recognized secret patterns before they hit the remote. It catches the major providers — AWS, Azure, GCP, Stripe, Twilio, and about 200 others.
GitGuardian does the same thing but with broader coverage and works across GitLab, Bitbucket, and self-hosted setups. Their free tier covers public repos.
Neither is perfect. Custom secrets — internal API tokens, self-signed JWTs, database passwords with no recognizable format — slip through both. But they catch the high-value targets.
Vault-based secrets management
HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault — pick one. The point is that secrets live in a purpose-built system, not in files scattered across developer machines and git histories.
// Instead of this:
const dbPassword = process.env.DB_PASSWORD; // sourced from .env file
// Your app pulls secrets at runtime from a vault:
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
const client = new SecretManagerServiceClient();
async function getDbPassword() {
const [version] = await client.accessSecretVersion({
name: 'projects/my-project/secrets/db-password/versions/latest',
});
return version.payload.data.toString('utf8');
}
// Secrets never touch the filesystem. Never enter git.
// Access is logged. Rotation is automated.
Yes, this is more complex than a .env file. Significantly more complex. But it also means you can rotate a database password without redeploying anything, audit who accessed which secret and when, and automatically revoke credentials that haven't been used in 90 days.
The Rotation Problem Nobody Talks About
Most teams that hardcode secrets also never rotate them. The Stripe key that went into production two years ago? Still active. The JWT signing secret? Same one since the project started. Database passwords? Set once, never changed.
Credential rotation is supposed to happen regularly. NIST recommends rotating high-value secrets every 60-90 days. In practice, teams rotate secrets after a breach, and sometimes not even then. A survey by 1Password found that 60% of IT professionals admit to reusing secrets across environments.
Vault solutions make rotation mechanical instead of heroic. AWS Secrets Manager can rotate RDS credentials automatically. Vault can generate short-lived database credentials that expire after hours. But you have to actually configure it, and that requires admitting that your current setup — the .env file with 47 variables that nobody fully understands — needs to go.
Cleaning Up an Already-Contaminated Repo
If secrets are already in your git history (they probably are), here's the damage control playbook:
First: revoke the exposed credentials. Not tomorrow. Now. Before you clean git history. Assume the secret is compromised the moment it was pushed.
Second: use BFG Repo-Cleaner or git-filter-repo. Not git filter-branch — that's painfully slow and error-prone on large repos.
# BFG is the fastest option for this
# Replace all occurrences of a specific string in all history
java -jar bfg.jar --replace-text passwords.txt repo.git
# passwords.txt contains:
# sk_live_abc123==>***REMOVED***
# Pr0d_p@ssw0rd!==>***REMOVED***
# Then clean up
cd repo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
# Force push. Every developer needs to re-clone.
git push --force
That last part — every developer needs to re-clone — is why teams avoid this. It's disruptive. But the alternative is leaving production credentials in a searchable history that any future compromise exposes.
What about GitHub's secret scanning alerts?
GitHub will notify you if it detects known secret formats in your repo. Some providers (AWS, Google, Slack) participate in their partner program and automatically revoke leaked tokens. But this is reactive, not preventive. You still committed the secret. The alert just tells you the house is already on fire.
CI/CD Pipelines Are a Goldmine for Attackers
Build logs. Teams dump environment variables into CI logs for debugging and forget to remove the debug step. Jenkins, GitHub Actions, GitLab CI — all of them will happily print your secrets to the build output if you tell them to.
# GitHub Actions - this happens more than you'd think
- name: Debug environment
run: env | sort
# Congrats, you just printed EVERY secret to the build log
# And GitHub Actions logs are accessible to anyone with repo read access
GitHub Actions masks secrets it knows about (ones defined in repo/org secrets). But if a secret comes from a vault call during the build, or gets constructed by concatenating non-secret values, the masking doesn't kick in. Developers find creative ways to accidentally expose credentials that no automated tool anticipates.
Automated Scanning Catches What Code Review Misses
Code reviewers focus on logic, architecture, maybe performance. Nobody is carefully inspecting every string literal for credential patterns during a PR review. Humans miss this stuff consistently — a base64-encoded token doesn't look like a secret to the naked eye. A 40-character hex string could be a git hash or an API key. Context matters, and reviewers lack the pattern database to catch everything.
ScanMyCode.dev runs automated secret detection as part of every security audit, scanning not just your current code but identifying patterns that suggest secrets might be loaded from insecure sources. You get exact file and line numbers, severity ratings, and remediation steps that actually make sense for your stack.
Stop Treating Secrets Like Code
Secrets are not code. They shouldn't be versioned like code, reviewed like code, or deployed like code. They're operational data with a lifecycle — creation, distribution, rotation, revocation — that's completely different from your application source.
The moment you accept that, the solution becomes obvious: secrets go in a secrets manager, access gets logged and audited, rotation happens automatically, and your git history stays clean.
But that first step — auditing what's already in your codebase and git history — that's the uncomfortable part. Because you'll almost certainly find things you didn't know were there.
Don't wait until a bot finds them first. Run a security audit on your codebase and get a full report within 24 hours — including hardcoded secrets, insecure secret loading patterns, and actionable fixes.