dotenv and friends: why your .env loader is a security risk
The dotenv package is one of the most-installed libraries in the JavaScript
and Python ecosystems, and it does one thing: read a .env file and put its
contents into the process environment. It is tiny, convenient, and almost
never reviewed. The uncomfortable part is that its convenience is the risk. A
.env loader does not protect your secrets — it moves them from one
plain-text location into memory, on every boot, for every process, often for
secrets the process does not need.
Plain text is the baseline problem
A .env file is a plain-text file with KEY=VALUE lines. Everything that
applies to the file applies to the loader: it is readable by anyone with
filesystem access, it gets copied into container images and archives, and it
lives in repositories that are one force-push mistake away from public. The
loader adds no encryption, no access control, and no audit log. It is a
parser, not a vault.
Parsing behavior you did not ask for
dotenv-style loaders are permissive by design, and the details differ between
implementations. Some strip inline comments, some do not. Some accept
export prefixes, some break on them. Some support variable interpolation,
some treat ${VAR} as a literal string. These differences matter because
they are silent: a value containing # or $ can be truncated in one
loader and kept in another, and the app boots with a wrong or missing key.
The failure surfaces later, at runtime, in a place that has nothing to do
with the file. When a loader silently skips a malformed line, the only
signal you get is an outage.
Secrets in the environment are still secrets in the open
The loader’s output — the process environment — is not a safe place either. Any process running as the same user can read another process’s environment, debuggers and tracing tools expose it, and container runtimes and orchestrators routinely log or snapshot it. Worse, the environment is inherited: every child process receives the entire dump, not just the one key it needs. A helper script that needs a single database URL gets the whole vault of API keys. Least privilege is structurally impossible when the distribution mechanism is a shared copy of everything.
What to do instead
The fixes are practical, not exotic:
- Keep
.envout of anything that gets built, committed, or shipped: CI configs, container images, and package archives included. - Load explicitly, not automatically. Prefer a loader that runs where you
call it over one that hooks the runtime and reads
.envon every import. - Do not re-export the loaded values into the environment of every child process. Pass what a specific command needs.
- Treat the file as a dev-only convenience, never as the production source of truth for credentials.
The loader is not the vault
The fix is not a better loader. Any loader that reads plain text is bound by the same ceiling: the secret is a string on disk, readable by whatever can read the disk. The durable answer is a secret manager — pass, Bitwarden, or a vault product — with a retrieval step that injects secrets into the specific process that needs them, at the point of use, instead of dumping everything into a shared environment.
That is the model behind trustless, a credential broker CLI for AI agents that reads an existing pass or Bitwarden store and injects secrets into a subprocess environment, scrubbing them from captured output so the loader’s job never becomes the leak. The commands and their behavior are documented under commands.
When was the last time you actually read the dotenv loader your project depends on — and do you know what it does with a malformed line?