Stop Leaking API Keys: I've Seen This Go Wrong Too Many Times
I've personally watched three developers go through the same nightmare. The pattern is always identical: they commit an AWS access key to a public GitHub repo. Within minutes — not hours, minutes — automated bots that scan every public commit find the key. Crypto miners spin up dozens of expensive EC2 instances. The developer doesn't notice until they get an email from AWS about "unusual activity" or, worse, a bill notification. The fastest I've seen it happen was under 90 seconds from push to exploitation.
Secret management isn't glamorous. It's not the kind of topic that gets conference talks. But it's the difference between a normal Tuesday and an incident that costs you thousands of dollars and a weekend of cleanup. This is basic, and most people still get it wrong.
The Golden Rule
Never hardcode secrets. Never commit .env files to Git. No exceptions, no "just for testing."
// ❌ NEVER — this is live on GitHub right now in thousands of repos
const stripe = new Stripe('sk_live_abc123actualkey456');
// ✅ ALWAYS — read from environment
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);"But I'll change it before I push." No, you won't. Or you'll forget one file. Or you'll commit it to a branch you think is private. The only safe approach is to never have the secret in source code in the first place.
Level 1: .env Files Done Right
This is the starting point. Every Node.js developer knows about
.env.env.gitignore# .env (NEVER commit this — real values here)
STRIPE_SECRET_KEY=sk_live_abc123
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=super-long-random-secret-here
# .gitignore — this MUST exist before your first commit
.env
.env.local
.env.production# .env.example (SAFE to commit — placeholder values, shows what's needed)
STRIPE_SECRET_KEY=sk_live_your_key_here
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=generate-with-openssl-rand-hex-64Gotcha: The
.gitignore.envAnother gotcha:
.env.local.env.development.env.production.envLevel 2: Fail Fast with Startup Validation
Your app should refuse to start if required secrets are missing. Discovering a missing environment variable at 3am when a specific code path runs for the first time is a terrible debugging experience. Fail loudly, fail early.
const required = ['DATABASE_URL', 'JWT_SECRET', 'STRIPE_SECRET_KEY'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:', missing);
process.exit(1); // Fail fast, not at 3am in production
}I put this in every project's startup file. It takes 30 seconds to write and has saved me hours of debugging. Some teams use libraries like
envalidLevel 3: When You've Already Committed a Secret
It happens. Don't panic, but act fast. The order matters here.
# Step 1: REVOKE THE SECRET IMMEDIATELY in your provider dashboard
# This is the FIRST thing you do. Not later. Not after cleaning the repo. NOW.
# The secret is compromised the moment it hits a public repo.
# Step 2: Remove from git history (after revoking)
git filter-repo --path .env --invert-paths
# Step 3: Force push to overwrite the history
git push origin --force --allCritical: Step 1 is the only one that actually protects you. Steps 2 and 3 are cleanup. By the time you've noticed the commit, bots have already found the secret. Cleaning git history is important for hygiene, but revoking the credential is the security fix. Don't spend 20 minutes figuring out git filter-repo while the compromised key is still active.
Also, be aware that GitHub caches repository content. Even after you force-push, the old commit may be accessible via its SHA for some time. Revoking the key is the only reliable protection.
Level 4: Production Secret Management
For anything running in production, environment variables loaded from
.envimport { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({ region: 'us-east-1' });
const response = await client.send(new GetSecretValueCommand({ SecretId: 'prod/myapp/db' }));
const { username, password } = JSON.parse(response.SecretString);AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, and Azure Key Vault all serve the same purpose: centralized, audited, access-controlled secret storage. They add complexity, but they also give you:
- Audit trails: Who accessed which secret, when
- Rotation: Automatically rotate database passwords, API keys
- Access control: IAM-based permissions for who can read which secrets
- No plaintext on disk: Secrets are fetched at runtime, not stored in files
The "it depends" part: If you're a solo developer running a side project on a single VPS, Secrets Manager is overkill. A well-managed
.envchmod 600.gitignoreLevel 5: Automated Secret Detection
Prevention is better than cleanup. Set up scanning that catches secrets before they reach your repository.
Pre-commit hooks with tools like
gitleaksdetect-secretsGitHub Secret Scanning (Settings > Security > Secret scanning) scans your repository for known secret patterns from partner services (AWS, Stripe, etc.) and alerts you. It even works retroactively on existing commits. Enable this immediately — it's free for public repos and available on GitHub Advanced Security for private repos.
Gotcha about CI/CD: Your CI/CD pipeline needs secrets too (deployment credentials, API keys for testing). Never put these in your workflow files. Use your platform's secret management (GitHub Actions secrets, GitLab CI variables) and be aware that secrets can leak through log output if you're not careful with your build scripts. We cover CI/CD security in more detail in our GitHub Actions security guide.
The One-Sentence Summary
If I had to pick the single most impactful thing you can do today: go to your GitHub settings, enable secret scanning on every repository you own, and then run
git log --all --diff-filter=A -- .envDiscussion
0 comments
Share your thoughts
No comments yet. Be the first to share your thoughts!