Secrets in Environment Variables: The Leaks You Don't See
Photo: Unsplash
"Don't hardcode secrets — put them in environment variables." It's the first piece of security advice most of us internalize, and it's not wrong. Moving an API key out of source code and into process.env is a real improvement. The trouble is that a lot of people stop there, treating environment variables as a vault. They aren't a vault. They're a slightly better hiding spot, and they leak in ways you don't notice until the key shows up somewhere it shouldn't.
The leaks are quiet precisely because env vars feel solved. Nobody audits them. They get inherited by child processes, dumped into logs, baked into images, and committed in .env files that someone forgot to gitignore. This article is a tour of those failure modes and what to do instead.
Leak #1: The .env File in Git
The most common leak isn't subtle at all — it's the .env file committed to the repository. Developers create one for local work, it fills up with real credentials, and one git add . later it's in history forever. Deleting it in a later commit does nothing; it's still in the history, and on a public repo it's already been scraped.
# Check before you ever push — is anything secret-shaped tracked?
git ls-files | grep -E '\.env($|\.)'
# .gitignore it from day one
echo ".env" >> .gitignore
echo ".env.*" >> .gitignore
echo "!.env.example" >> .gitignore # keep a placeholder templateThe OWASP Secrets Management Cheat Sheet treats source control as one of the highest-risk leak paths for exactly this reason. The fix is prevention: gitignore from the start, scan history, and use pre-commit hooks that block commits containing high-entropy strings.
Once a secret lands in git history or a published image layer, consider it compromised — deleting it later does not un-leak it. The only safe response to an exposed secret is to rotate it immediately, not to quietly remove the file.
Leak #2: Logs and Error Reports
Environment variables have a way of ending up in logs, and this one bites even careful teams. A crash handler dumps process.env for debugging. An error tracker captures the full request context. A "print all config on startup" line that was meant to be temporary ships to production.
// Looks helpful. Leaks every secret to your log aggregator.
console.log('Booting with config:', process.env);
// And this innocent-looking error reporter sends env to a third party
process.on('uncaughtException', (err) => {
reporter.send({ error: err, env: process.env }); // don't
});Once a secret is in your logging pipeline, it's now in every system that ingests those logs — your aggregator, your backups, your third-party error service, and anyone with read access to any of them. Treat logs as a public-ish surface. Maintain an explicit denylist of keys to redact, and never serialize whole config objects.
Leak #3: Child Processes and Inheritance
Environment variables are inherited by every child process you spawn, by default. That utility you shell out to, that third-party CLI, that build script — they all receive your entire environment, secrets included. If any of them logs its environment or gets compromised, your keys went along for the ride.
const { spawn } = require('child_process');
// Bad: the child inherits DATABASE_URL, STRIPE_KEY, everything
spawn('some-cli', ['--do-thing']);
// Better: pass only what the child actually needs
spawn('some-cli', ['--do-thing'], {
env: { PATH: process.env.PATH, TOOL_TOKEN: process.env.TOOL_TOKEN },
});The principle is least privilege applied to the environment: a process should only see the secrets it genuinely uses.
Leak #4: Container Images and Build Args
Containers add their own traps. A secret passed as a Docker build argument, or COPY'd in as a .env file, becomes part of an image layer — and layers are cached, pushed to registries, and inspectable by anyone who can pull the image. docker history will happily show build args back to you.
# Build args and copied env files persist in image layers
docker history my-app:latest # can reveal ARG-passed secrets
docker run --rm my-app:latest env # what does the running image expose?The right move is to inject secrets at runtime, not build time — via your orchestrator's secret mechanism — so they never become a durable part of the artifact. The same goes for CI: never echo a secret, and rely on your CI's masked-secret store rather than plain pipeline variables where you can.
What to Do Instead
Environment variables are an acceptable delivery mechanism for secrets into a running process. They are a poor storage and management mechanism. The distinction is the whole point.
- Use a dedicated secrets manager as the source of truth — a cloud KMS-backed vault or equivalent. Your app fetches secrets at startup or reads them from a mounted, tmpfs-backed file; the canonical copy lives somewhere with access control and an audit trail, following the OWASP Secrets Management guidance.
- Rotate routinely and on exposure. If rotation is painful, you'll avoid it exactly when you need it most. Automate it so a leaked key has a short useful life.
- Scan continuously. Run secret scanning in pre-commit hooks and in CI so a leak is caught at the door, not in a breach report.
- Scope and least-privilege every credential. A key that can only do one narrow thing is a smaller prize and a smaller incident.
- Redact at the logging layer. Centralize redaction so no individual
console.logcan undo it.
For local development, you can read more about how process.env is populated and the conventions around it in the Node.js process.env reference — useful context for understanding exactly which processes can see what.
The Mindset Shift
The reframe that fixes most of this: a secret in an environment variable is in flight, not at rest. It's a copy, delivered to one process, that can be inherited, logged, imaged, and committed. Your job is to control the canonical secret — where it's stored, who can read it, how it rotates, and how fast you'd notice if it leaked. Env vars are the last hop in that chain, not the chain itself.
Takeaways
- Environment variables are a fine delivery mechanism but a poor vault — don't stop at "move it to env" and call it secure.
- The quiet leaks are committed
.envfiles, secrets in logs and error reports, environment inheritance by child processes, and credentials baked into image layers. - A leaked secret in git history or an image layer is compromised — rotate it, don't just delete the file.
- Pass only the secrets a child process needs, redact at the logging layer, and inject secrets at container runtime rather than build time.
- Use a dedicated secrets manager as the source of truth, with least-privilege scoping, routine rotation, and continuous scanning.
- Think of an env-var secret as in flight, not at rest — control the canonical copy, its access, and its rotation.

