Hardcoded Secrets in Repo — The Breach That Starts With a Git Push

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Hardcoded secrets in source code are the most common and most avoidable security breach vector in software engineering. They happen because environment variable management feels like friction at 2 AM when you just need something to work. The API key goes in the file, the commit happens, and even if you delete it in the next commit, it persists in git history forever — and GitHub's secret scanning bots and public forks will find it before you do.

Why Hardcoded Secrets Are Permanent

Once a secret enters version control, the clock starts:

T+0:    Developer commits API key to fix a test locally
T+5s:   GitHub receives the push
T+30s:  GitHub secret scanning detects it (if enabled)
T+2min: Automated bots scanning public repos find it
T+10min: Key is being used in bot requests against your quota
T+3hr:  Key shared in underground forums and Telegram channels
T+1day: Twelve external services are billing against your account
T+7day: You discover abnormal API usage in your billing dashboard
 
Meanwhile:
- Git history preserves the key in every clone and fork
- Anyone who pulled the repo before deletion still has it
- CI/CD logs may have printed it as an environment variable
- IDE history, snippets, and backups all contain it

Even private repos are not safe. Former employees retain access to clones. Accidental visibility changes happen. GitHub's audit logs do not delete what was exposed.

What Gets Hardcoded and Gets Leaked

The most commonly leaked secret categories in production codebases:

1. Database credentials
   DATABASE_URL=postgresql://admin:password123@prod-db.rds.amazonaws.com/app
 
2. Payment API keys
   STRIPE_SECRET_KEY=sk_live_abc123...
   SQUARE_ACCESS_TOKEN=EAAAl...
 
3. Cloud provider credentials
   AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
   AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfi...
 
4. AI service keys
   OPENAI_API_KEY=sk-proj-...
   ANTHROPIC_API_KEY=sk-ant-...
 
5. JWT signing secrets
   JWT_SECRET=my-super-secret-key-that-looks-short
 
6. Private keys and certificates
   -----BEGIN RSA PRIVATE KEY-----
   MIIEowIBAAKCAQEA1234...

Any of these in a commit can cause immediate financial and security damage.

Fix 1: Environment Variables With Startup Validation

The foundational rule is never put a secret in code — reference it from the environment:

// Bad: never do this
const stripe = require('stripe')('sk_live_abc123xyz789')
const db = { url: 'postgresql://admin:pass@prod.db:5432/app' }
 
// Good: reference from environment
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
 
// Better: validate all required secrets at startup with clear errors
function loadConfig() {
  const required = [
    'DATABASE_URL',
    'STRIPE_SECRET_KEY',
    'SENDGRID_API_KEY',
    'JWT_SECRET',
  ]
 
  const missing = required.filter(key => !process.env[key])
  if (missing.length > 0) {
    throw new Error(
      'Missing required environment variables:\n' +
      missing.map(k => '  - ' + k).join('\n') +
      '\nSet these before starting the application.'
    )
  }
 
  return {
    databaseUrl: process.env.DATABASE_URL,
    stripeKey: process.env.STRIPE_SECRET_KEY,
    sendgridKey: process.env.SENDGRID_API_KEY,
    jwtSecret: process.env.JWT_SECRET,
  }
}
 
// This throws at startup if any required secret is missing
const config = loadConfig()

Failing loudly at startup is correct behavior. A service that starts without its required secrets will fail silently in the middle of production traffic, which is worse.

Fix 2: .env Files That Never Get Committed

Use .env for local development and make sure git never sees real values:

# .gitignore — always include these patterns
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*
 
# Commit only a template with placeholder values
# .env.example — this IS committed to the repo
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-and-paste-here

The .env.example file serves as documentation. New team members copy it, fill in real values locally, and the real .env file stays out of git entirely.

Fix 3: Pre-Commit Hooks That Catch Secrets

Block common secret patterns before they reach the remote:

#!/bin/bash
# .husky/pre-commit
 
# Check staged files 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-----|sk-proj-|sk-ant-)" \
  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

Install GitLeaks for more comprehensive coverage — it includes over 150 secret patterns including cloud providers, payment processors, and AI services.

Fix 4: Automated Secret Scanning in CI

Run secret scanning on every push, not just on commit:

# .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 — secrets hide in old commits
 
      - name: GitLeaks scan
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
      - name: TruffleHog scan
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD

TruffleHog scans git history, not just current state. A secret deleted in the latest commit is still found in the history scan.

Fix 5: Secrets Manager for Production Environments

In production, environment variables are better managed by a dedicated secrets manager than by raw environment injection:

const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager')
 
const client = new SecretsManagerClient({ region: 'us-east-1' })
 
async function getSecret(secretName) {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretName })
  )
  return response.SecretString
}
 
async function loadProductionSecrets() {
  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 disk or environment variable listings
// Rotation happens in AWS without code changes
// Access is audited via CloudTrail

AWS Secrets Manager, HashiCorp Vault, and GCP Secret Manager all support automatic rotation, audit logging, and fine-grained access control. Environment variables visible in process listings (ps auxe) are exposed to anyone who can exec into your container.

Key Takeaways

  • A secret committed to git history is permanently compromised — deletion does not remove it from existing clones or forks
  • Automated bots scan public GitHub repositories within minutes of a push; private repos offer limited protection if visibility ever changes
  • Use environment variables for local development and a secrets manager for production
  • Always commit a .env.example with placeholder values and keep .env in .gitignore
  • Pre-commit hooks with GitLeaks stop secrets before they reach the remote
  • CI/CD secret scanning with TruffleHog catches secrets across the full git history, not just the latest diff
  • When a secret is potentially exposed, revoke it immediately before investigating — the 30 seconds it takes to revoke is the most important action
  • Kubernetes secrets mounted as files are safer than environment variables, which appear in process listings and pod spec dumps

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading