Secrets & Configuration
A single exposed API key can compromise your entire infrastructure. This article covers practical secrets management: keeping credentials out of Git, managing them across environments, rotating them without downtime, and preventing accidental leaks. Whether you're building a solo project or managing enterprise infrastructure, these practices will save you from the 3 AM "we've been breached" call.
The Golden Rule
Never commit secrets to Git. Ever. Not "just for local testing." Not "in a private repo." Not "encrypted." Once a secret hits Git history, assume it's compromised.
Why this matters:
- Git history is permanent (rebasing doesn't truly delete)
- Private repos get cloned, forked, and shared
- Credentials in history get scraped by automated bots within minutes
- Even if you delete the branch, the commit exists in reflog
# This has happened to every developer at some point
git add .
git commit -m "fix bug"
git push
# Realized .env was committed
git rm .env
git commit -m "remove secrets"
# The secret is STILL in history and already scraped
What Counts as a Secret?
Anything that grants access or proves identity:
// These are ALL secrets
const secrets = {
apiKeys: 'sk-1234567890abcdef', // Third-party service access
databaseUrls: 'postgres://user:pass@host', // Database credentials
jwtSecrets: 'your-256-bit-secret', // Token signing keys
encryptionKeys: 'AES-256-key-here', // Data encryption
oauthSecrets: 'github-client-secret', // OAuth app secrets
webhookSecrets: 'stripe-webhook-secret', // Webhook verification
privateKeys: '-----BEGIN RSA PRIVATE KEY', // SSH, TLS, signing keys
passwords: 'admin-password', // Any password
tokens: 'ghp_xxxxxxxxxxxxxxxxxxxx', // Personal access tokens
};
These are NOT secrets (but may still be sensitive):
- Public API keys (like publishable Stripe keys, some analytics IDs)
- Configuration that doesn't grant access
- Feature flags
When in doubt, treat it as a secret.
Local Development Setup
The .env Pattern
Use .env files for local development, never in production:
# .env.example (COMMIT THIS - it's a template)
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=generate-a-random-string
# .env (NEVER COMMIT THIS)
DATABASE_URL=postgres://user:actualpassword@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_51ABC123actualkey
JWT_SECRET=xK9#mP2$vL7@nQ4!
.gitignore Configuration
# .gitignore
# Environment files
.env
.env.local
.env.*.local
.env.development
.env.production
# But NOT the template
!.env.example
!.env.template
# Other common secrets
*.pem
*.key
*.p12
*.pfx
credentials.json
serviceAccountKey.json
secrets/
.secrets/
Loading Environment Variables
Node.js with dotenv:
// Load as early as possible in your app
import 'dotenv/config';
// Or with options
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
// Access variables
const dbUrl = process.env.DATABASE_URL;
Bun (built-in support):
// Bun automatically loads .env files
// Access directly
const dbUrl = Bun.env.DATABASE_URL;
// or
const dbUrl = process.env.DATABASE_URL;
Validate on startup:
// src/config.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
PORT: z.coerce.number().default(3000),
});
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:');
console.error(result.error.format());
process.exit(1);
}
return result.data;
}
export const env = validateEnv();
// Usage
import { env } from './config';
console.log(env.PORT); // Type-safe access
Multiple Environment Files
# Load order (later files override earlier)
.env # Shared defaults
.env.local # Local overrides (gitignored)
.env.development # Development-specific
.env.production # Production-specific
// Loading based on NODE_ENV
import dotenv from 'dotenv';
dotenv.config({ path: '.env' });
dotenv.config({ path: '.env.local', override: true });
dotenv.config({ path: `.env.${process.env.NODE_ENV}`, override: true });
dotenv.config({ path: `.env.${process.env.NODE_ENV}.local`, override: true });
Production Patterns
Environment Variables (12-Factor Standard)
The 12-Factor App methodology recommends environment variables for production configuration. They're:
- External to code
- Easy to change per deployment
- Supported by every platform
Docker:
# Dockerfile
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
# Don't bake secrets into the image!
# ENV DATABASE_URL=... # WRONG
CMD ["node", "server.js"]
# docker-compose.yml
services:
app:
build: .
environment:
- NODE_ENV=production
# Reference from host environment or .env
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
env_file:
- .env.production # Or load from file (still gitignored)
Kubernetes:
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
database-url: "postgres://..."
jwt-secret: "..."
---
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
Cloud Platforms:
# Heroku
heroku config:set DATABASE_URL=postgres://...
heroku config:set JWT_SECRET=...
# Vercel
vercel env add DATABASE_URL
vercel env add JWT_SECRET
# Fly.io
fly secrets set DATABASE_URL=postgres://...
# Railway
railway variables set DATABASE_URL=postgres://...
When to Upgrade to Secret Managers
Environment variables work well until:
- Multiple applications share secrets
- You need audit logs of secret access
- Secrets need automatic rotation
- You're in a regulated industry (HIPAA, PCI-DSS, SOC 2)
- Team members need different access levels
AWS Secrets Manager
// Install: pnpm add @aws-sdk/client-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<Record<string, string>> {
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await client.send(command);
if (response.SecretString) {
return JSON.parse(response.SecretString);
}
throw new Error('Secret not found');
}
// Usage
const secrets = await getSecret('prod/myapp/database');
const dbUrl = secrets.DATABASE_URL;
# AWS CLI
aws secretsmanager create-secret \
--name prod/myapp/database \
--secret-string '{"DATABASE_URL":"postgres://...","JWT_SECRET":"..."}'
# Get secret
aws secretsmanager get-secret-value --secret-id prod/myapp/database
Google Secret Manager
// Install: pnpm add @google-cloud/secret-manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();
async function getSecret(name: string): Promise<string> {
const [version] = await client.accessSecretVersion({
name: `projects/my-project/secrets/${name}/versions/latest`,
});
return version.payload?.data?.toString() || '';
}
// Usage
const jwtSecret = await getSecret('jwt-secret');
# gcloud CLI
gcloud secrets create jwt-secret --data-file=./jwt-secret.txt
# Access secret
gcloud secrets versions access latest --secret=jwt-secret
HashiCorp Vault
Vault is the gold standard for enterprise secrets management:
// Install: pnpm add node-vault
import vault from 'node-vault';
const client = vault({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN,
});
async function getSecret(path: string): Promise<Record<string, string>> {
const result = await client.read(`secret/data/${path}`);
return result.data.data;
}
// Usage
const secrets = await getSecret('myapp/production');
const dbUrl = secrets.database_url;
# Vault CLI
vault kv put secret/myapp/production \
database_url="postgres://..." \
jwt_secret="..."
vault kv get secret/myapp/production
Doppler (Modern Alternative)
Doppler provides a developer-friendly secrets management experience:
# Install CLI
brew install dopplerhq/cli/doppler
# Login and setup
doppler login
doppler setup
# Run with secrets injected
doppler run -- node server.js
# Access in code (secrets are environment variables)
const dbUrl = process.env.DATABASE_URL;
Secret Rotation
Secrets should be rotated regularly: immediately if compromised, periodically as hygiene. The goal is rotating without downtime.
Rotation Strategy: Dual Valid Secrets
// Support multiple valid secrets during rotation
const VALID_JWT_SECRETS = [
process.env.JWT_SECRET_CURRENT,
process.env.JWT_SECRET_PREVIOUS,
].filter(Boolean);
function verifyToken(token: string): JWTPayload | null {
for (const secret of VALID_JWT_SECRETS) {
try {
return jwt.verify(token, secret) as JWTPayload;
} catch {
continue;
}
}
return null;
}
// Always sign with current
function signToken(payload: JWTPayload): string {
return jwt.sign(payload, process.env.JWT_SECRET_CURRENT!);
}
Rotation Process
- Generate new secret → Add as
CURRENT, move old toPREVIOUS - Deploy → Both secrets are valid
- Wait for cache/session expiry → Old tokens refresh with new secret
- Remove old secret → Only new secret is valid
# Example rotation script
#!/bin/bash
NEW_SECRET=$(openssl rand -base64 32)
# AWS example
aws secretsmanager update-secret \
--secret-id prod/myapp/jwt \
--secret-string "{\"current\":\"$NEW_SECRET\",\"previous\":\"$(get_current_secret)\"}"
# Trigger deployment to pick up new secret
./deploy.sh
Database Password Rotation
// For databases, create two users and alternate
// User A is active, User B is standby
async function rotateDbPassword() {
// 1. Generate new password for standby user
const newPassword = generateSecurePassword();
// 2. Update standby user's password in database
await adminDb.query(
'ALTER USER standby_user PASSWORD $1',
[newPassword]
);
// 3. Update secret manager with new credentials
await updateSecret('db-credentials', {
active: 'standby_user',
active_password: newPassword,
standby: 'primary_user',
standby_password: oldPrimaryPassword,
});
// 4. Trigger rolling restart of application
await triggerDeployment();
}
Leak Prevention
Pre-commit Hooks
Block commits containing secrets before they happen:
# Install git-secrets
brew install git-secrets
# Add to repo
cd your-repo
git secrets --install
git secrets --register-aws
# Add custom patterns
git secrets --add 'sk_live_[a-zA-Z0-9]{24}' # Stripe live keys
git secrets --add 'ghp_[a-zA-Z0-9]{36}' # GitHub tokens
git secrets --add 'xox[baprs]-[a-zA-Z0-9-]+' # Slack tokens
Or use detect-secrets (more comprehensive):
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
# Generate baseline (marks existing false positives)
detect-secrets scan > .secrets.baseline
# Audit baseline interactively
detect-secrets audit .secrets.baseline
CI/CD Scanning
Scan for secrets in your pipeline:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for scanning
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: main
extra_args: --only-verified
- name: Gitleaks scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Handling Leaked Secrets
When a secret is exposed (it will happen eventually):
- Revoke immediately - Don't wait to assess damage
# AWS - Deactivate key
aws iam update-access-key --access-key-id AKIA... --status Inactive
# GitHub - Revoke token in Settings > Developer settings > Personal access tokens
# Stripe - Roll API keys in Dashboard > Developers > API keys
Rotate to new secret - Deploy with replacement
Audit access - Check logs for unauthorized use
# AWS CloudTrail example
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIA... \
--start-time 2024-01-01
- Clean up Git (if source of leak)
# Remove from history with BFG
bfg --replace-text passwords.txt repo.git
# Or git-filter-repo
git filter-repo --invert-paths --path .env
- Post-mortem - How did this happen? How do we prevent it?
Secret Scanning Services
Enable GitHub's secret scanning (free for public repos):
- Settings → Code security and analysis → Secret scanning
For private repos and more providers:
- GitGuardian - Monitors GitHub, GitLab, Bitbucket
- Snyk - Includes secret detection
- TruffleHog - Open source, runs anywhere
Environment-Specific Patterns
Development vs Production
// Different secrets for different environments
const config = {
development: {
// Use weak secrets locally for easier debugging
jwtSecret: 'dev-secret-not-for-production',
// Use test credentials from providers
stripeKey: 'sk_test_...',
// Local database
databaseUrl: 'postgres://localhost:5432/myapp_dev',
},
production: {
// Strong secrets from secret manager
jwtSecret: await getSecret('jwt-secret'),
stripeKey: await getSecret('stripe-secret-key'),
databaseUrl: await getSecret('database-url'),
},
};
Staging Isolation
Staging should use:
- Separate secret storage from production
- Separate database with anonymized data
- Test API keys from third-party services
- Different encryption keys
# Separate secret paths
aws secretsmanager get-secret-value --secret-id staging/myapp/database
aws secretsmanager get-secret-value --secret-id production/myapp/database
# Never share secrets across staging/production
Local Development Without Real Secrets
// Fake credentials for local development
// .env.development
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_fake # Use Stripe's test mode
JWT_SECRET=local-dev-secret-minimum-32-chars
// Or use mocks
if (process.env.NODE_ENV === 'development') {
const stripe = {
charges: {
create: async () => ({ id: 'ch_mock_123', status: 'succeeded' }),
},
};
}
Security Checklist
Setup (Do Once)
- Add
.envto.gitignore - Create
.env.examplewith placeholder values - Install pre-commit hooks for secret detection
- Configure CI secret scanning
- Document secret locations for your team
Every New Secret
- Generate with sufficient entropy (32+ bytes for symmetric keys)
- Store in appropriate secret manager for environment
- Add to
.env.examplewith placeholder - Document what the secret is for
- Set up rotation schedule if applicable
Regular Maintenance
- Rotate secrets on a schedule (quarterly minimum)
- Audit who has access to secrets
- Review and remove unused secrets
- Check for secrets in logs, error messages, responses
- Run secret scanning on historical commits
Generating Secure Secrets
# Using OpenSSL (available everywhere)
openssl rand -base64 32
# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Using /dev/urandom (Unix)
head -c 32 /dev/urandom | base64
# For passwords (human-readable)
openssl rand -base64 24 | tr -d '/+=' | head -c 24
// In code
import crypto from 'crypto';
// For API keys, tokens
function generateApiKey(): string {
return crypto.randomBytes(32).toString('base64url');
}
// For passwords
function generatePassword(length = 24): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
const bytes = crypto.randomBytes(length);
return Array.from(bytes, (b) => chars[b % chars.length]).join('');
}
See Also
- Docker Deployment Guide - Secure container secrets
- Kubernetes - K8s secrets management
- HTTP Security Headers - Related production security
- OWASP Secrets Management Cheat Sheet
- 12-Factor App: Config - Configuration principles
- AWS Secrets Manager Docs
- HashiCorp Vault Docs