Back to articles
devsecops
8 min readFebruary 18, 2026

Stop Leaking API Keys: I've Seen This Go Wrong Too Many Times

Somebody commits an AWS key to GitHub. Bots find it in seconds. A crypto miner spins up 200 instances. The bill arrives. I've watched this happen three times. Here's how to never be that person.

Stop Leaking API Keys: I've Seen This Go Wrong Too Many Times

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."

javascript
// ❌ 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
files, but the number of repos I've seen with
.env
files committed (or worse, not even having a
.gitignore
) is alarming.

bash
# .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
bash
# .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-64

Gotcha: The

.gitignore
rule only prevents future commits of the file. If
.env
was already committed before you added the gitignore rule, it's still in your git history. And git history is forever (more on this below).

Another gotcha:

.env.local
,
.env.development
,
.env.production
— frameworks like Next.js and Vite load different env files automatically. Make sure ALL of them are in your gitignore, not just
.env
.

Level 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.

javascript
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

envalid
or Zod to add type validation on top of existence checks, which is even better — catching a malformed DATABASE_URL at startup instead of when the first query fails is worth the minimal setup.

Level 3: When You've Already Committed a Secret

It happens. Don't panic, but act fast. The order matters here.

bash
# 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 --all

Critical: 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

.env
files are... fine, but not great. The secrets still exist as plain text on the server's filesystem or in your deployment platform's configuration. For higher security requirements, use a dedicated secrets manager.

javascript
import { 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

.env
file with proper file permissions (
chmod 600
) and a solid
.gitignore
is sufficient. If you're on a team, deploying to multiple environments, or handling customer data — invest in a proper secrets manager. The cost is minimal compared to the cost of a breach.

Level 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

gitleaks
or
detect-secrets
scan your staged changes for patterns that look like API keys, passwords, or tokens. They run locally before the commit is created, so the secret never enters git history.

GitHub 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 -- .env
to check if you've ever committed an env file. Those two actions take five minutes and eliminate the most common way secrets get leaked.

Discussion

0 comments

Share your thoughts

No comments yet. Be the first to share your thoughts!