Putting secrets in environment variables is the default advice from a decade of twelve-factor app guidance, and it is genuinely better than hardcoding them in source. But "better than the worst option" is not the same as safe. Environment variables are process-global, inherited by every child process, dumped in crash reports, printed by well-meaning debug code, and — in containers — baked permanently into image layers. In September 2025 the self-replicating npm worm known as Shai-Hulud made the risk vivid: after infecting a package's install script, it used a credential-scanning tool to sweep the environment and the filesystem of every machine it reached, harvesting cloud tokens and API keys straight out of the process environment and republishing itself. Env vars were exactly where it looked first, because that is where it knew secrets would be. This guide explains where environment-variable secrets escape and what to do about it.
How environment variables leak secrets
The property that makes env vars convenient — global availability to a process and everything it spawns — is precisely what makes them leak. Every child process inherits the full environment, so a subprocess you shell out to, or a compromised dependency's install script, can read every secret you set. On Linux, a process's environment is readable at /proc/PID/environ, so any code running as the same user can dump it. When an application crashes, error-reporting tools and core dumps frequently capture the entire environment and ship it to a third-party service. CI systems are notorious offenders: a single printenv or set -x in a build script prints every secret to logs that may be world-readable, and misconfigured pipelines echo secrets into artifacts. And in Docker, using an ENV instruction or a build argument to inject a secret writes it into the image layer permanently, where anyone who pulls the image can extract it with docker history.
Safer patterns, with commands
Inject secrets at runtime from a secrets manager, not from a baked-in environment. Fetch the secret when the process starts and hold it in memory rather than exporting it into the shell environment where children inherit it:
# Fetch at startup instead of exporting a long-lived env var
DB_PASSWORD="$(aws secretsmanager get-secret-value \
--secret-id prod/app-db --query SecretString --output text)"
# Pass it only to the one process that needs it, not the whole shell
DB_PASSWORD="$DB_PASSWORD" ./app
Never bake secrets into container images. Use build-time secret mounts (which do not persist in layers) or mount secrets at runtime, and verify nothing leaked into the history:
# BuildKit secret mount — not written to any image layer
docker build --secret id=npmrc,src=$HOME/.npmrc .
# Audit an image for accidentally baked-in secrets
docker history --no-trunc myimage:latest
Keep secrets out of CI logs. Register them as masked secrets so the platform redacts them, and never enable command tracing in a step that touches a secret:
# In a shell step: do NOT do this near a secret
# set -x # prints every command and its expanded arguments
# printenv # dumps the entire environment to the log
Scrub secrets from crash and error reporting. Configure your error-reporting SDK to strip environment data, and disable core dumps for processes that hold secrets with ulimit -c 0.
Environment-variable risk checklist
| Risk | Mitigation |
|---|---|
| Child processes inherit all secrets | Pass secrets only to the specific process that needs them |
/proc/PID/environ readable by same user | Fetch to memory; avoid exporting long-lived env vars |
| Crash dumps capture the environment | Scrub env data in error reporting; disable core dumps |
| CI logs print secrets | Use masked secrets; never set -x or printenv near them |
Docker ENV/build args persist in layers | Use BuildKit secret mounts or runtime mounts |
| Forgotten secrets linger in the environment | Prefer short-lived, runtime-fetched credentials |
How Safeguard's secret scanning helps
The environment is only one hop from source control — secrets get into env vars because they were written into a .env file, a CI config, or a Dockerfile first. Safeguard's secret scanning inspects those files across your repositories and full git history, flagging committed .env files, hardcoded values in CI YAML, and secrets embedded in Dockerfiles before they ever reach a running environment. Griffin AI validates which findings are live credentials and ranks them by blast radius, cutting the noise of test placeholders. Developers keep secrets out of committed config by running the Safeguard CLI in pre-commit, and because these findings sit alongside dependency risk in the same software composition analysis view — the same class of supply-chain exposure the Shai-Hulud worm abused — you triage from one prioritized list. When a hardcoded env value can be replaced with a managed reference, auto-fix opens the pull request.
Bring continuous secret and dependency scanning to your configs and containers — get started free or read the documentation.
Frequently Asked Questions
Are environment variables actually a bad place for secrets?
They are better than hardcoding secrets in source, but they carry real risks: every child process inherits them, they appear in crash dumps and /proc, and they are easy to leak into CI logs. Treat env vars as an acceptable transport for short-lived, runtime-fetched secrets, not as a durable store — and never commit the files that populate them.
Why is baking a secret into a Docker image with ENV dangerous?
Because it becomes a permanent part of the image layer. Anyone who can pull the image can recover the value with docker history or by inspecting the layers, even if a later instruction appears to unset it. Use BuildKit secret mounts for build-time secrets and mount secrets at runtime so nothing sensitive is ever written into a distributable layer.
How did the Shai-Hulud worm use environment variables?
After compromising npm packages in September 2025, the worm ran credential-scanning tooling on every machine it infected and swept the process environment and filesystem for cloud tokens and API keys. Environment variables were a primary target precisely because they are where applications conventionally store secrets, which is a strong argument for short-lived credentials that are worthless once the process ends.
How do I stop secrets from ending up in CI logs?
Register every secret as a masked variable so the platform redacts it from output, and never enable command tracing such as set -x or run printenv in a step that has access to a secret. Review build scripts for anywhere a secret could be echoed into logs or written into a published artifact, since those logs and artifacts often outlive the pipeline run.