Managing Secrets & API Keys
A secret committed to source control is a public secret — even in a private repo. GitHub's secret scanning, anyone with repo access, a leaky git log, or a misconfigured CI artifact can expose it. The damage compounds: a leaked database password or API key can persist for years, since services don't know the secret was compromised. This page covers how to load secrets safely via environment variables, the layered tooling available, and what to do when a secret leaks.
The cardinal rule
Environment variables — the baseline
// Load from the environment — never hardcode: const DB_URL = process.env.DATABASE_URL // ✅ const JWT_SECRET = process.env.JWT_SECRET // ✅ const API_KEY = process.env.STRIPE_SECRET_KEY // ✅ // ❌ NEVER: const DB_URL = 'postgresql://user:password@host/db' const JWT_SECRET = 'my-secret-key'
dotenv for local development
npm install dotenv
.env (NEVER commit this file)
DATABASE_URL=postgresql://postgres:password@localhost/myapp JWT_SECRET=local-dev-secret-replace-in-prod STRIPE_SECRET_KEY=sk_test_...
app.js — load as early as possible
import 'dotenv/config' // or: import dotenv from 'dotenv'; dotenv.config() // After this line, process.env is populated from .env
.gitignore — always
# Never commit: .env .env.local .env.*.local # A template with fake values is fine to commit: .env.example
Validating required secrets at startup
config/env.js — fail fast if secrets are missing
const required = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL']
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`)
}
}
// Export typed config — centralises all env reads:
export const config = {
dbUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
redisUrl: process.env.REDIS_URL,
port: Number(process.env.PORT ?? 3000),
isProd: process.env.NODE_ENV === 'production',
}Secrets managers for production
Service | How it works |
|---|---|
AWS Secrets Manager / SSM | Fetch secrets at runtime via SDK; IAM controls access |
HashiCorp Vault | Dedicated secrets engine; dynamic credentials support |
GCP Secret Manager | Managed secret storage; IAM-gated access |
Kubernetes Secrets | Injected as env vars or mounted volumes; encrypt at rest |
Doppler / 1Password Secrets | Developer-friendly dashboards that sync to platforms |
AWS SSM — fetch a secret at startup
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'
const ssm = new SSMClient({ region: process.env.AWS_REGION })
const { Parameter } = await ssm.send(new GetParameterCommand({
Name: '/myapp/prod/jwt-secret',
WithDecryption: true,
}))
process.env.JWT_SECRET = Parameter.ValueSecrets rotation and revocation
Rotate regularly — database passwords, API keys, and JWT secrets should change periodically.
Revoke immediately when a secret is suspected leaked — don't wait.
Use short-lived credentials where the platform supports them (AWS IAM roles, OIDC tokens).
Audit access logs — who fetched which secret, when.
Never log secrets — strip them from request logs, error details, and debug output.
If a secret leaks
1. Revoke the secret IMMEDIATELY at the provider (GitHub, Stripe, AWS console). 2. Issue a new secret and rotate it into all environments. 3. Audit access logs for the window between creation and revocation. 4. Remove from git history (git filter-repo, BFG) — but assume it was already seen. 5. Post-mortem: how did it leak? Add the detection/prevention control.