The leak that made me paranoid about process.env
A junior engineer added console.log({ env: process.env }) while debugging a Stripe webhook. The log shipped to our hosted aggregator. Within an hour the dashboard showed API keys, database URLs, and a JWT secret sitting in plaintext search results. We rotated everything. It cost a Saturday.
Environment variables are convenient, not safe by default. They are readable by the process, often inherited by child processes, and easy to dump accidentally. Treat them as a delivery mechanism for secrets, then minimize how long and how widely those values exist in memory and logs.
Secret scanners in CI are cheap insurance. They will not catch everything, but they stop the classic accidental commit.
What belongs in env vars—and what does not
I put deployment-specific config and short-lived credentials in the environment: database URLs, API keys, feature flags that differ per env. I do not put large JSON blobs, private keys longer than necessary, or anything that should be different per request.
Public config can live in committed files. Secrets must not. The failure mode is mixing them: one “config” object that includes both NODE_ENV and STRIPE_SECRET_KEY, then logging the whole object.
- OK in env: DB_URL, REDIS_URL, API keys, signing secrets
- Commit freely: port defaults, feature names, non-secret timeouts
- Never commit: .env with real values, service account JSON, PEM files
- Prefer a secret manager for production; env injection at deploy time
Local dotenv without polluting git
I still use dotenv locally. Production never depends on a .env file on disk. The app reads process.env that the platform injects. That split keeps “works on my laptop” from becoming “we SCP a secrets file onto the box.”
Commit .env.example with placeholder values and comments. Add .env and .env.*.local to .gitignore before the first commit. I also add a pre-commit secret scan on the team repo after watching someone force-add a .env “just this once.”
# .env.example — safe to commit
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
JWT_SECRET=replace-me-with-long-random
STRIPE_SECRET_KEY=sk_test_replace_me
# .gitignore
.env
.env.*
!.env.example
Load once, validate early, never print
I centralize env loading in one module. It fails fast if required keys are missing. Spreading process.env.FOO across fifty files makes it impossible to know what is required for boot.
Validation with zod or joi catches empty strings and wrong types before the server accepts traffic. That is better than a cryptic failure deep in a Stripe call at 2 a.m.
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
export const env = EnvSchema.parse(process.env);
// Never do this:
// logger.info('boot', { env });
export function redactedEnvSummary() {
return {
NODE_ENV: env.NODE_ENV,
PORT: env.PORT,
DATABASE_URL: env.DATABASE_URL.replace(/:\/\/.*@/, '://***@'),
JWT_SECRET: '[redacted]',
STRIPE_SECRET_KEY: '[redacted]',
};
}
Docker and CI traps
ENV lines in Dockerfiles with real secrets bake values into image layers forever. Use runtime -e flags, orchestration secrets, or build args only for non-secrets. I have pulled “old” images from a registry and found keys from a previous customer still in the history.
In CI, map secrets from the vault or GitHub Actions secrets into env for the job. Do not echo them in scripts. Mask them in the CI UI. Be careful with set -x in bash—it prints expanded variables.
Child processes inherit the full environment. If you spawn a CLI tool you do not trust, scrub the env you pass it.
Build args in Docker are visible in image history. Never pass secrets as build-arg “just for compile time” unless you use multi-stage builds that truly discard them—and even then prefer runtime injection.
# Bad: secret baked into image
# ENV STRIPE_SECRET_KEY=sk_live_...
# Better: inject at runtime
# docker run -e STRIPE_SECRET_KEY --env-file /run/secrets/app.env myapp
# In Node when spawning untrusted tools:
import { spawn } from 'node:child_process';
spawn('some-tool', [], {
env: { PATH: process.env.PATH, HOME: process.env.HOME },
});
Rotation and least privilege
Assume every secret will eventually leak. Design for rotation: short TTLs where the provider allows it, dual-key windows for JWT signing, and runbooks that do not require a full outage.
Give each service its own credentials. A marketing site should not share the production database user that can DROP TABLE. When the marketing deploy pipeline leaked, isolation saved the core API.
Checklist I run before every Node release
I keep this boring and repeatable. Boring is the point.
- No .env with real secrets in the repo or Docker image
- Required env validated at boot with clear errors
- Log redaction helpers used; no raw process.env dumps
- CI secrets masked; no set -x around secret expansion
- Production secrets from a manager or platform injection
- Rotation steps documented for DB, JWT, and payment keys
Twelve-factor habits that still apply
Config via environment remains right for 12-factor style apps. The update for 2026 is that production secrets increasingly come from a manager injected as env at process start, not from a long-lived file on disk. The process still sees process.env; the operators stop copying .env around Slack.
I keep a written inventory of every secret the app needs, who owns rotation, and the blast radius if it leaks. When the Stripe key leaked in logs, that inventory cut our response time: we knew exactly which services to restart and which dashboards to watch for fraud.
For monorepos, each package gets its own required env schema. Sharing one giant env object across web, worker, and CLI guarantees the CLI inherits production database credentials it never needed.
Runtime hardening around secrets in memory
You cannot fully erase secrets from a Node process, but you can reduce exposure. Avoid putting secrets on the prototype of widely logged objects. Prefer short-lived clients that close. Do not attach API keys to global.graphqlContext dumps used in error pages.
In workers, clear job payloads that included credentials after use when you control the queue schema. Bull and similar systems persist job data—never store raw secrets inside job JSON.
If a secret might have leaked, rotate first and investigate second. Argument about “maybe the log filter caught it” wastes the window attackers need.
- Inventory secrets with owners and rotation owners
- Per-package env schemas in monorepos
- No secrets in job payloads or error page dumps
- Rotate on suspicion, then do forensics
Example: stripping secrets from error reports
Error trackers love to serialize the world. I keep a denylist of env key substrings—SECRET, TOKEN, PASSWORD, KEY, PRIVATE—and scrub matching values before events leave the process. Framework defaults are not enough; custom context objects still leak.
I also scrub Authorization headers and cookie values in HTTP error breadcrumbs. A single 500 on /checkout should not upload a session token to a SaaS you forgot was enabled in staging.
const SECRETISH = /(secret|token|password|passwd|api[_-]?key|private)/i;
function scrub(value) {
if (value == null) return value;
if (Array.isArray(value)) return value.map(scrub);
if (typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
out[k] = SECRETISH.test(k) ? '[redacted]' : scrub(v);
}
return out;
}
return value;
}
// Sentry-style beforeSend
function beforeSend(event) {
event.extra = scrub(event.extra);
event.contexts = scrub(event.contexts);
return event;
}
Team process that keeps secrets boring
Onboarding docs should show how to get local secrets from 1Password or the company vault—not from a zip file in Drive. Shared .env files in chat are a process failure.
When someone leaves the team, rotate shared secrets they could access. Individual cloud IAM keys are easier to revoke than a single DATABASE_URL everyone copied.
I schedule a quarterly secret review: list env keys in production, confirm each is still needed, confirm rotation dates. Unused keys get deleted. Unused credentials still unlock systems.
Key takeaways
- Env vars deliver secrets; they do not protect them from logs, children, or images.
- Commit .env.example only; keep real .env files out of git and Docker layers.
- Validate required env at boot in one module and fail fast on missing keys.
- Never log process.env; log a redacted summary of non-secret config.
- Inject secrets at runtime in production—prefer a secret manager over files on disk.
- Isolate credentials per service and practice rotation before you need it.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.