July 16, 2026
Environment variables are not secrets management

Every backend project I’ve worked on, including my own, starts the same way: a .env file, a database password in it, .env added to .gitignore, and everyone moves on feeling like the secrets problem is solved. It isn’t solved. It’s deferred, and it stays deferred right up until the moment something reads an environment variable that nobody expected to be able to read it, at which point the deferred problem becomes an incident.
What env vars were actually built for
Environment variables are a process-configuration mechanism. They exist so a program can behave differently depending on where it’s running, PORT=8080 in one place, PORT=3000 in another, without changing code. That’s the entire design brief. Nothing about the mechanism was built with confidentiality in mind, and once you notice that, a list of things env vars quietly do becomes uncomfortable instead of routine:
- Any child process a program spawns inherits its full environment by default, including every secret in it, whether or not that child process needed to see any of them. A logging library that shells out to
git rev-parsefor a version string, a build tool, a health-check script, all of them get a copy of the database password sitting three variables away in the same environment. - Most crash reporters and a fair number of logging libraries dump the process environment into error reports by default, because it’s genuinely useful debugging context, and “useful debugging context” and “your Stripe key” live in the exact same list with no distinction between them.
/proc/<pid>/environon Linux is readable by anything running as the same user or root, so any other process on that box, including one from a dependency with a supply-chain compromise, can read your whole environment without needing to touch your application code at all.- Container orchestration UIs, CI dashboards, and process managers routinely have a “show environment” debug view, meant for troubleshooting a stuck deploy, that renders every variable in plaintext to whoever has read access to that dashboard, a much larger set of people than whoever has access to the actual secret store.
None of these are exotic attacks. They’re ordinary tooling behaving exactly as designed, the same way the circuit breaker post from earlier today talked about ordinary retry logic making an ordinary slow dependency worse. The mechanism isn’t broken, it’s just being asked to do a job, holding a value in a way that a specific narrow set of code can read and nothing else can, that it was never built for.
Why “.env is gitignored” isn’t the finish line
.gitignore-ing the .env file stops one specific leak, the value ending up in source control history, which is a real and common leak worth stopping. But it does nothing about any of the paths above, because none of them route through git. I’ve seen a .env file correctly excluded from every commit for two years straight, while the same secrets it held were visible in a CI system’s “show job environment” debug panel to every engineer with read access to that CI project, which was most of the company. The file was safe. The value it held was not.
What an actual secrets store buys you that an env var doesn’t
The thing a dedicated secrets manager (Vault, AWS Secrets Manager, SOPS with age/GPG, even a simpler encrypted-file-plus-decrypt-on-boot setup) adds isn’t obfuscation, the secret is still eventually plaintext in memory somewhere, that’s unavoidable, it’s access control and an audit trail on the read itself. A secrets manager can answer “which service, running as which identity, read this credential, and when” after the fact. An environment variable can’t answer that question even in principle, because by the time you’d ask it, the value has already been copied into every child process, every crash report, and every debug view that happened to touch that process, with no record of which one actually used it.
That access-control layer is also what makes rotation survivable. If a credential leaks and it’s only ever lived in one secrets-manager entry with tracked reads, you rotate it there and you have a real list of what needs to pick up the new value. If it’s been copied as a bare env var into a dozen services, three CI pipelines, and a couple of debug dashboards over the years, “rotate the password” turns into an archaeology project, because nobody actually knows every place a plaintext copy is currently sitting.
What I actually do differently now
I still use .env files, and I’m not arguing against that for pure local development, config that only ever needs to answer “what port do I run on locally” is exactly what env vars are for and a secrets manager would be overkill for it. What changed is where the line sits: anything that’s a genuine credential, a database password, an API key, a signing secret, doesn’t go into a plain env var past local dev. It goes into whatever secrets store the deploy target actually supports, injected into the process at the last possible moment, and the env var, if one exists at all in production, holds a reference (a path, a secret ARN) rather than the value itself.
The uncomfortable part of writing this one is admitting how long “it’s in .env and .env is gitignored” felt like a finished sentence to me. It’s a correctly finished sentence about source control. It was never a finished sentence about secrets.