- Published on
Hardcoded Secrets in Repo — The Breach That Starts With a Git Push
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Hardcoded secrets in source code are the most common and most avoidable security breach vector in software. They happen because environment variable management feels like friction at 2 AM when you just need this thing to work. The API key goes in the file, the commit happens, and even if you delete it in the next commit, it's in git history forever — and GitHub's secret scanning, bots, and public forks will find it before you do.
- Why Hardcoded Secrets Are Permanent
- What Gets Hardcoded (and Gets Leaked)
- Fix 1: Environment Variables for All Secrets
- Fix 2: .env Files That Never Get Committed
- Fix 3: Secret Scanning in CI
- Fix 4: Secrets Manager for Production
- Fix 5: Rotate After Any Potential Exposure
- Secrets Security Checklist
- Conclusion
Why Hardcoded Secrets Are Permanent
Timeline of a leaked secret:
T+0: Developer commits API key to fix test locally
T+5s: GitHub receives the push
T+30s: GitHub secret scanning detects it (if enabled)
T+2min: Automated bots scanning GitHub find it
T+10min: Key is being used in bot requests
T+3hr: Key has been shared in underground forums
T+1day: 12 external services are using your key
T+7day: You discover abnormal API usage in billing
Meanwhile:
- Git history preserves the key forever
- Anyone who cloned the repo has it
- CI/CD logs may have printed it
- IDE history, snippets, backups have it
What Gets Hardcoded (and Gets Leaked)
Common secrets found in repos:
1. Database credentials
DATABASE_URL=postgresql://admin:password123@prod-db.rds.amazonaws.com:5432/myapp
2. API keys
STRIPE_SECRET_KEY=sk_live_abc123...
SENDGRID_API_KEY=SG.abc123...
OPENAI_API_KEY=sk-proj-...
3. AWS credentials
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
4. JWT signing secrets
JWT_SECRET=my-super-secret-key
5. OAuth credentials
GITHUB_CLIENT_SECRET=abc123def456
GOOGLE_CLIENT_SECRET=GOCSPX-abc123
6. Private keys and certificates
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA1234...
Fix 1: Environment Variables for All Secrets
// ❌ Never do this
const stripe = new Stripe('sk_live_abc123xyz789')
const db = new Pool({ connectionString: 'postgresql://admin:pass@prod.db:5432/app' })
// ✅ Always use environment variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const db = new Pool({ connectionString: process.env.DATABASE_URL! })
// ✅ Validate all required secrets at startup
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
SENDGRID_API_KEY: z.string().startsWith('SG.'),
JWT_SECRET: z.string().min(32),
AWS_REGION: z.string(),
})
// This throws at startup if any required secret is missing
export const env = envSchema.parse(process.env)
// Usage:
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
Fix 2: .env Files That Never Get Committed
# .gitignore — always include these
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*
# ✅ Commit a template with no real values
# .env.example — this IS committed
DATABASE_URL=postgresql://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_your_key_here
SENDGRID_API_KEY=SG.your_key_here
JWT_SECRET=generate-with-openssl-rand-base64-32
# .env — NOT committed, contains real values
DATABASE_URL=postgresql://admin:real_password@prod.db:5432/myapp
STRIPE_SECRET_KEY=sk_live_real_key_here
# Pre-commit hook to detect secrets before they're committed
# .husky/pre-commit
#!/bin/bash
# Check for common secret patterns
if git diff --cached --name-only | xargs grep -l \
-E "(sk_live_|SG\.|AKIA[A-Z0-9]{16}|-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----)" \
2>/dev/null; then
echo "❌ Potential secret detected in staged files. Aborting commit."
echo "Move secrets to .env and use process.env instead."
exit 1
fi
Fix 3: Secret Scanning in CI
# .github/workflows/secret-scan.yml
name: Secret Scan
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for git-secrets
# GitLeaks — comprehensive secret scanner
- name: GitLeaks scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TruffleHog — scans git history, not just current state
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
Fix 4: Secrets Manager for Production
// Instead of env vars in production, use a secrets manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'
const client = new SecretsManagerClient({ region: 'us-east-1' })
async function getSecret(secretName: string): Promise<string> {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
)
return response.SecretString!
}
// Load all secrets at startup
async function loadSecrets() {
const [stripeKey, sendgridKey, jwtSecret] = await Promise.all([
getSecret('prod/myapp/stripe-secret-key'),
getSecret('prod/myapp/sendgrid-api-key'),
getSecret('prod/myapp/jwt-secret'),
])
return { stripeKey, sendgridKey, jwtSecret }
}
// Secrets never touch environment variables or disk
// They're rotated in AWS without code changes
// Access is audited via CloudTrail
// Kubernetes: secrets as mounted files, not env vars
// (env vars are visible in process listings)
// deployment.yaml
// volumeMounts:
// - name: stripe-secret
// mountPath: /secrets/stripe
// readOnly: true
import * as fs from 'fs'
function loadMountedSecret(path: string): string {
return fs.readFileSync(path, 'utf-8').trim()
}
const stripeKey = loadMountedSecret('/secrets/stripe/key')
Fix 5: Rotate After Any Potential Exposure
// secret-rotation-checklist.ts — run when a secret may have been exposed
const rotationChecklist = {
immediate: [
'Revoke the exposed key in the provider dashboard NOW',
'Check provider logs for unauthorized usage in last 30 days',
'Issue new key and deploy immediately',
'Audit what the key had access to',
],
within24Hours: [
'Rotate all secrets that used the same password/pattern',
'Review git history: `git log -p --all -- "**/*.env" | grep -E "sk_|SG\\.|AKIA"`',
'Check if repo is public or was ever public',
'Notify affected services if shared key',
],
postMortem: [
'Add GitLeaks to CI pipeline',
'Enable GitHub secret scanning',
'Document how the secret leaked',
'Update .gitignore and pre-commit hooks',
'Move to secrets manager if not already using one',
],
}
// Check git history for all secrets (run after a potential leak)
// git log --all -p | grep -E "(sk_live|SG\.|AKIA)" | head -20
Secrets Security Checklist
- ✅ No secrets in source code, ever — use
process.envor a secrets manager - ✅
.envis in.gitignore— commit.env.examplewith placeholder values - ✅ Pre-commit hook catches common secret patterns before they're committed
- ✅ CI pipeline runs GitLeaks or TruffleHog on every push
- ✅ GitHub secret scanning enabled on all repos
- ✅ Production uses AWS Secrets Manager, Vault, or Kubernetes secrets
- ✅ Secrets are rotated on a schedule and immediately after any suspected exposure
- ✅ Access to secrets is audited — who accessed what and when
Conclusion
A hardcoded secret is a permanent breach, not a temporary mistake. Git history is immutable, bots scan GitHub continuously, and by the time you notice abnormal API usage, the key has been active for days. The prevention is simple: never put secrets in code — use environment variables for local development, a .gitignore that covers all .env variants, a pre-commit hook that rejects common secret patterns, and a secrets manager in production. The rotation playbook matters too: when a secret is exposed, revoke it before you do anything else. The 30 seconds it takes to revoke is worth more than any investigation.