Your .env file has your database password, your Stripe secret key, and your JWT signing secret. It's also in your git history from that one commit six months ago. Let's fix this.
The .env Problem
Every Node.js tutorial starts the same way:
# .env
DATABASE_URL=postgres://admin:password123@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_live_abc123
JWT_SECRET=my-super-secret
import dotenv from 'dotenv';
dotenv.config();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
This works on your laptop. In production, it's a liability.
Here's why: .env files are unencrypted plaintext sitting on disk. They get copied into Docker images, committed to repos, logged by process managers, and leaked through error dumps. They have no access control, no audit trail, and no rotation mechanism.
The rule is simple: .env for development, secret managers for everything else.
Secret Managers: The Production Way
A secret manager gives you encrypted storage, access control, audit logs, and rotation — all the things a flat file doesn't.
Here's a practical wrapper that abstracts your secret source:
interface SecretProvider {
get(key: string): Promise<string>;
getMultiple(keys: string[]): Promise<Record<string, string>>;
}
class AWSSSMProvider implements SecretProvider {
private client: SSMClient;
private cache: Map<string, { value: string; expiry: number }> = new Map();
private ttl: number;
constructor(region: string, ttlMs = 300_000) {
this.client = new SSMClient({ region });
this.ttl = ttlMs;
}
async get(key: string): Promise<string> {
const cached = this.cache.get(key);
if (cached && cached.expiry > Date.now()) return cached.value;
const command = new GetParameterCommand({
Name: key,
WithDecryption: true,
});
const result = await this.client.send(command);
const value = result.Parameter?.Value ?? '';
this.cache.set(key, { value, expiry: Date.now() + this.ttl });
return value;
}
async getMultiple(keys: string[]): Promise<Record<string, string>> {
const entries = await Promise.all(
keys.map(async (k) => [k, await this.get(k)] as const)
);
return Object.fromEntries(entries);
}
}
For local development, you swap in a provider that reads .env:
class LocalEnvProvider implements SecretProvider {
async get(key: string): Promise<string> {
return process.env[key] ?? '';
}
async getMultiple(keys: string[]): Promise<Record<string, string>> {
return Object.fromEntries(keys.map((k) => [k, process.env[k] ?? '']));
}
}
// Factory — one line decides the strategy
function createSecretProvider(): SecretProvider {
if (process.env.NODE_ENV === 'production') {
return new AWSSSMProvider(process.env.AWS_REGION!);
}
return new LocalEnvProvider();
}
Your application code never knows where secrets come from. That's the point.
Runtime Injection vs Build-Time
This distinction matters more than most people think.
Build-time means baking secrets into your artifact — Docker image, compiled bundle, deployed package. Anyone with access to the artifact has the secret.
Runtime means the process fetches secrets when it starts (or later). The artifact contains zero sensitive data.
# BAD: build-time injection
FROM node:20-alpine
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
COPY . .
RUN npm run build
# GOOD: runtime injection — no secrets in the image
FROM node:20-alpine
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
# Secrets injected via ECS task definition, K8s secrets, or fetched at boot
Your application should bootstrap secrets at startup:
async function bootstrap(): Promise<AppConfig> {
const secrets = createSecretProvider();
const [dbUrl, jwtSecret, stripeKey] = await Promise.all([
secrets.get('/myapp/prod/database-url'),
secrets.get('/myapp/prod/jwt-secret'),
secrets.get('/myapp/prod/stripe-key'),
]);
return Object.freeze({ dbUrl, jwtSecret, stripeKey });
}
// Pass config explicitly — no global process.env access
const config = await bootstrap();
const app = createApp(config);
Object.freeze prevents accidental mutation. Passing config as an explicit dependency makes testing trivial and secrets auditable — you can grep every place config.stripeKey is used.
Secret Rotation Without Downtime
Secrets should rotate. Passwords expire, keys get compromised, compliance demands it. Your app needs to handle this without restarting.
class RotatingSecretProvider {
private provider: SecretProvider;
private current: Record<string, string> = {};
private refreshInterval: NodeJS.Timeout | null = null;
constructor(provider: SecretProvider) {
this.provider = provider;
}
async start(keys: string[], intervalMs = 60_000): Promise<void> {
await this.refresh(keys);
this.refreshInterval = setInterval(() => this.refresh(keys), intervalMs);
}
private async refresh(keys: string[]): Promise<void> {
try {
const updated = await this.provider.getMultiple(keys);
const changed = keys.filter((k) => updated[k] !== this.current[k]);
if (changed.length > 0) {
console.log(`Rotated secrets: ${changed.join(', ')}`);
this.current = updated;
}
} catch (err) {
// Keep old secrets on failure — don't crash
console.error('Secret refresh failed, keeping current values', err);
}
}
get(key: string): string {
return this.current[key] ?? '';
}
stop(): void {
if (this.refreshInterval) clearInterval(this.refreshInterval);
}
}
The important detail: on refresh failure, keep the old secrets. Don't crash your app because the secret manager had a blip. Log it, alert on it, but keep serving.
For database credentials specifically, the dual-credential pattern works well:
async function createResilientPool(secrets: RotatingSecretProvider) {
// Primary connection string
let pool = new Pool({ connectionString: secrets.get('db-url-primary') });
// Health check with automatic failover to rotated credentials
setInterval(async () => {
try {
await pool.query('SELECT 1');
} catch {
const freshUrl = secrets.get('db-url-primary');
pool = new Pool({ connectionString: freshUrl });
}
}, 30_000);
return { query: (...args: any[]) => pool.query(...args) };
}
AWS RDS and Vault both support dual-credential rotation — the old credential stays valid while the new one propagates. Your app just needs to retry with fresh values on auth failure.
Common Mistakes
Committing .env to git. Even if you delete it, it's in the history. Use git-secrets or a pre-commit hook:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/awslabs/git-secrets
hooks:
- id: git-secrets
If it's already in your history, git filter-repo is the fix. Rotate every secret that was ever committed — assume it's been seen.
Logging secrets accidentally. This one is insidious:
// BAD — logs the entire config including secrets
console.log('Starting with config:', config);
// BAD — error objects can contain connection strings
catch (err) {
console.error('DB failed:', err); // err.message may include the URL
}
// GOOD — explicit allowlist of what gets logged
console.log('Starting with config:', {
region: config.region,
port: config.port,
dbHost: new URL(config.dbUrl).hostname, // host only, no creds
});
Using process.env everywhere. When secrets are scattered across 40 files as process.env.WHATEVER, you can't audit access, can't rotate, and can't test cleanly. Read secrets once at startup, pass them through explicitly.
Same secrets across environments. Your staging Stripe key should not work in production. Namespace secrets by environment: /myapp/prod/stripe-key vs /myapp/staging/stripe-key. Never share credentials between environments.
No secret access alerts. Your secret manager can log every access. Set up alerts for unusual patterns — access from new IPs, access spikes, access to secrets a service shouldn't need. This is your early warning system.
Hardcoding "temporary" secrets. There's nothing more permanent than a temporary hardcoded API key. If a secret exists, it goes in the secret manager. No exceptions, no "I'll fix it later."
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
If this was useful, consider:
- Sponsoring on GitHub to support more open-source tools
- Buying me a coffee on Ko-fi
Top comments (0)