At some point in the last decade, the industry arrived at a consensus: secrets go in environment variables. Twelve-Factor App said so. Every PaaS made it the default. It became the answer so obvious that questioning it felt like questioning source control.
I'm not here to say environment variables are wrong for config. They're fine for config — non-sensitive values that change between environments. But secrets are different, and the properties that make env vars convenient are the same properties that make them dangerous for sensitive material.
How secrets leak from env vars
The most common leak vector isn't someone reading your shell history. It's your own application.
Crash reporters. Sentry, Datadog, Bugsnag — they all capture process state on unhandled exceptions. Depending on configuration, that state can include the full environment. A single unhandled crash in a misconfigured service can exfiltrate every secret in your process environment to a third-party SaaS.
Debug logging. It's remarkably easy to accidentally log process.env during startup for debugging and then forget to remove it. The log goes to CloudWatch, Datadog, or Papertrail, and your secrets are now in a log index that's queryable by anyone with access.
Child process inheritance. In Node.js, child processes spawned with child_process.spawn inherit the parent's environment by default unless you explicitly pass { env: {} }. If that child runs user-provided code — a plugin system, a sandboxed eval, a test runner — your secrets are in scope.
"The surface area of an environment variable is the entire process. You grant access to every library, every dependency, every crash reporter, and every child process simultaneously. That\"'\"s not a secret — that\"'\"s a broadcast."
What I use instead
For production systems, I've moved to a pattern where secrets are fetched at startup from a secrets manager (AWS Secrets Manager or HashiCorp Vault, depending on the infrastructure) and stored in a module-scoped object that's never attached to process.env.
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
let secrets: AppSecrets | null = null;
export async function loadSecrets(): Promise<AppSecrets> {
if (secrets) return secrets; // singleton — load once
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
const result = await client.send(new GetSecretValueCommand({
SecretId: process.env.SECRET_ID, // only this non-sensitive ref in env
}));
secrets = JSON.parse(result.SecretString!);
return secrets;
}The only thing in process.env is the secret ID — a non-sensitive pointer. The actual values are fetched once at startup, validated with Zod, and stored in a typed module-level object. They're never in the environment. They're never logged. They're never inherited.
Rotation without restarts
One argument for env vars is that they're baked in at startup, which makes the lifecycle predictable. The counterargument is that baking secrets in at startup means rotation requires a restart, which means rotation doesn't happen as often as it should.
With a secrets manager, you can implement background refresh — fetch the latest version every hour, compare to the current value, and swap if they differ. Your application rotates secrets without downtime, and security teams can mandate rotation intervals without coordinating deployments.
I've implemented this in Supergate for the LLM provider API keys. Every 30 minutes, a background timer re-fetches the secrets and updates the in-memory store. The gateway never needs to restart for a key rotation, which means rotations actually happen.
For local development
None of this is practical for local development, and I'm not suggesting you run a secrets manager locally. For dev, I still use .env files — but with one discipline: .env files hold dummy development values only. Anything that hits a real external service (Stripe test keys, database credentials, even staging API keys) gets stored in ~/.config/app/dev-secrets.json, outside the repo, with read permissions locked to my user.
The goal is a clean separation between "config that can go in source control" and "credentials that cannot." Environment variables blur that line because they look the same whether they contain a port number or a private key.
The practical summary
I'm not saying delete your .env files today. I'm saying that env vars are a convenience that accumulated a security reputation they don't fully deserve, and the industry has better tools now. If you're building a product that handles sensitive data — payment credentials, API keys with write access, database passwords — a secrets manager is not over-engineering. It's the correct baseline.
The threat isn't the attacker who has read access to your server. It's the bug you didn't know about that logged "something helpful" and landed your database password in Papertrail.