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 .env out 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 .env on 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?