A

Secrets & Configuration

securitysecretsenvironment-variablesdotenvkmsvaultconfiguration

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

  1. Generate new secret → Add as CURRENT, move old to PREVIOUS
  2. Deploy → Both secrets are valid
  3. Wait for cache/session expiry → Old tokens refresh with new secret
  4. 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):

  1. 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
  1. Rotate to new secret - Deploy with replacement

  2. 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
  1. 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
  1. 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:

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 .env to .gitignore
  • Create .env.example with 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.example with 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

Last updated: March 23, 2026