Environment Variable Risks
Environment variables are visible in process listings, inherited by child processes,
captured in crash dumps, and logged by every debugging tool -- they are the worst place
to store secrets
When to Use
- Evaluating how to pass secrets to applications at runtime
- Auditing existing applications that use environment variables for secrets
- Designing a safer alternative to
.env files and environment variable injection
- Understanding why secrets leak in CI/CD pipelines and container orchestrators
- Migrating from env-var-based secrets to a vault or mounted-file approach
Threat Context
The 12-factor app methodology (2011) recommended environment variables for configuration,
including secrets. This advice was reasonable for its time but has aged poorly.
Environment variables are exposed in: /proc/<pid>/environ on Linux (readable by
same-user processes), docker inspect output, Kubernetes pod descriptions
(kubectl describe pod), CI/CD build logs (many CI systems log env vars by default or on
error), crash dumps and core files, error reporting services (Sentry, Datadog, etc.
capture environment), child processes (env vars are inherited by all child processes,
including those you did not write). The 2021 Codecov supply chain attack exfiltrated
secrets by reading environment variables from thousands of CI/CD pipelines. The modified
Bash Uploader script silently sent every environment variable to an attacker-controlled
server, compromising CI tokens, AWS keys, and database credentials across the software
industry.
Instructions
Understand the leakage surface. Environment variables are: visible to any process
running as the same user via /proc/<pid>/environ, inherited by all child processes
and subshells, captured in core dumps, logged by many frameworks in debug mode, exposed
in container inspection commands, and often included in error reports sent to
third-party services. Every layer of the stack is a potential leak point. A single
console.log(process.env) in a debugging session, a stack trace in Sentry, or a
docker inspect command exposes every secret in the process environment.
Prefer mounted files over environment variables. Instead of
DATABASE_URL=postgres://user:pass@host/db, mount a file at
/var/secrets/database-url and read it at startup. Files can have restricted
permissions (0400, owned by the application user), are not inherited by child
processes, do not appear in process listings, and are not captured in crash dumps.
Kubernetes Secrets can be mounted as files. Vault Agent writes secrets to files in a
tmpfs mount (in-memory filesystem, never touches disk).
If you must use env vars, limit exposure. Read the env var once at startup, store
it in application memory, and unset the env var immediately
(delete process.env.DATABASE_URL in Node.js, os.environ.pop('DATABASE_URL') in
Python). This limits the window during which the secret is visible in /proc/environ.
However, this does not prevent inheritance by child processes started during the window,
and the string may persist in the process heap even after unsetting.
Audit CI/CD for env var logging. Many CI systems (GitHub Actions, GitLab CI,
CircleCI) mask secrets in logs but only if the secrets are registered as secret
variables. Unregistered env vars containing secrets are logged in plaintext on every
build. Audit all env vars in CI/CD for secrets that should be registered as
masked/secret variables. Use CI secret scanning tools (gitleaks, truffleHog) on CI
logs to detect accidental exposure.
Use runtime secret injection. Instead of setting env vars in Dockerfiles or
docker-compose files (which bakes them into images or version-controlled files), inject
secrets at runtime via: Kubernetes Secrets mounted as files, Vault Agent sidecar, cloud
provider secret injection (AWS Secrets Manager + ECS task definitions, GCP Secret
Manager + Cloud Run), or init containers that fetch secrets and write them to shared
volumes. The secret should never exist in a layer that is persisted or version
controlled.
Never put secrets in Dockerfiles or docker-compose.yml. ENV DATABASE_PASSWORD=hunter2
in a Dockerfile bakes the secret into every image layer. Docker layer caching means
the secret persists even if you delete it in a later layer (RUN unset does not remove
it from the layer that set it). Use multi-stage builds with the --secret flag
(BuildKit) or runtime injection. Similarly, docker-compose.yml with inline
environment values is version-controlled -- secrets in it are secrets in git.
Details
Leakage Vector Inventory
Every path through which environment variables can leak:
/proc/<pid>/environ -- readable by any process running as the same user on Linux
ps e command -- shows environment of running processes
docker inspect <container> -- displays all env vars in the container config
kubectl describe pod -- shows env vars defined in the pod spec
heroku config -- displays all config vars in plaintext
- CI/CD build logs -- many systems echo env vars on failure or in debug mode
- Crash dumps and core files -- include the full process environment
- Error reporting services -- Sentry, Bugsnag, Datadog APM capture environment
- Child process inheritance -- every spawned subprocess inherits all env vars
- Shell history -- if secrets are set via
export SECRET=value on command line
.env files committed to git -- even with .gitignore, mistakes happen
- Docker image layers --
ENV instructions are baked into the image
- Terraform state files -- store plaintext values of env vars set via
TF_VAR_*
Safer Alternatives Comparison
| Method |
Leakage Risk |
Rotation Support |
Audit Trail |
Complexity |
| Environment variables |
High |
Manual |
None |
Low |
| Mounted files (tmpfs) |
Low |
Manual/Auto |
OS-level |
Low |
| Vault Agent injection |
Low |
Automatic |
Full |
Medium |
| K8s CSI Secret Store |
Low |
Auto-sync |
K8s audit |
Medium |
| SOPS-encrypted files |
Medium |
Manual |
Git history |
Low |
| Runtime API fetch |
Low |
Automatic |
Full |
Medium |
The Codecov Attack Case Study
In January 2021, attackers modified Codecov's Bash Uploader script (used in CI/CD
pipelines across the industry) to exfiltrate all environment variables to an
attacker-controlled server. The attack persisted for two months before discovery. It
worked because CI pipelines routinely have dozens of secrets in environment variables --
AWS keys, database credentials, API tokens, signing keys -- and any script with process
access can read them all. Affected organizations included Twitch, Hashicorp, Confluent,
and hundreds of others.
12-Factor App Reinterpretation
The 12-factor app's "store config in environment" principle was about separating config
from code, not about security. The spirit of the principle (externalize configuration)
can be achieved more securely with mounted files, secret managers, or runtime injection.
The letter of the principle (use env vars specifically) has known security limitations
that its authors did not anticipate in 2011. Modern best practice: use env vars for
non-secret configuration (feature flags, log levels, service URLs) and mounted files or
vault injection for secrets.
Anti-Patterns
.env files committed to version control. Even with .gitignore, .env files
end up in git history through mistakes. Once committed, the secrets are in every clone
forever (until the repository is purged with BFG or git-filter-branch). Use
.env.example with placeholder values; actual .env files must never be committed.
Secrets in Docker Compose files. docker-compose.yml is version-controlled.
Secrets in it are secrets in git. Use docker-compose.yml with env_file pointing to
a file not in version control, or use Docker secrets for swarm mode deployments.
CI/CD secrets as unmasked env vars. If a CI secret is not registered as a masked
variable, any printenv or debug log step exposes it in plaintext build logs that may
be retained for months. Audit all CI/CD env vars and register secrets as masked/secret
variables in the CI system's secret management.
Trusting unset to remove secrets. Unsetting an env var removes it from the
process environment listing but does not scrub it from memory (the string may persist
in the heap until garbage collected or overwritten). It also does not affect child
processes that already inherited the variable before the unset.
Using the same env var across all environments. DATABASE_URL set to the
production database URL in development environments means a development machine
compromise leaks production credentials. Use environment-specific secret injection with
separate credentials per environment. Development should use development-only
credentials that cannot access production data.
1---2name: security-environment-variable-risks3description: Environment Variable Risks4---5# Environment Variable Risks67> Environment variables are visible in process listings, inherited by child processes,8> captured in crash dumps, and logged by every debugging tool -- they are the worst place9> to store secrets1011## When to Use1213- Evaluating how to pass secrets to applications at runtime14- Auditing existing applications that use environment variables for secrets15- Designing a safer alternative to `.env` files and environment variable injection16- Understanding why secrets leak in CI/CD pipelines and container orchestrators17- Migrating from env-var-based secrets to a vault or mounted-file approach1819## Threat Context2021The 12-factor app methodology (2011) recommended environment variables for configuration,22including secrets. This advice was reasonable for its time but has aged poorly.23Environment variables are exposed in: `/proc/<pid>/environ` on Linux (readable by24same-user processes), `docker inspect` output, Kubernetes pod descriptions25(`kubectl describe pod`), CI/CD build logs (many CI systems log env vars by default or on26error), crash dumps and core files, error reporting services (Sentry, Datadog, etc.27capture environment), child processes (env vars are inherited by all child processes,28including those you did not write). The 2021 Codecov supply chain attack exfiltrated29secrets by reading environment variables from thousands of CI/CD pipelines. The modified30Bash Uploader script silently sent every environment variable to an attacker-controlled31server, compromising CI tokens, AWS keys, and database credentials across the software32industry.3334## Instructions35361. **Understand the leakage surface.** Environment variables are: visible to any process37 running as the same user via `/proc/<pid>/environ`, inherited by all child processes38 and subshells, captured in core dumps, logged by many frameworks in debug mode, exposed39 in container inspection commands, and often included in error reports sent to40 third-party services. Every layer of the stack is a potential leak point. A single41 `console.log(process.env)` in a debugging session, a stack trace in Sentry, or a42 `docker inspect` command exposes every secret in the process environment.43442. **Prefer mounted files over environment variables.** Instead of45 `DATABASE_URL=postgres://user:pass@host/db`, mount a file at46 `/var/secrets/database-url` and read it at startup. Files can have restricted47 permissions (0400, owned by the application user), are not inherited by child48 processes, do not appear in process listings, and are not captured in crash dumps.49 Kubernetes Secrets can be mounted as files. Vault Agent writes secrets to files in a50 tmpfs mount (in-memory filesystem, never touches disk).51523. **If you must use env vars, limit exposure.** Read the env var once at startup, store53 it in application memory, and unset the env var immediately54 (`delete process.env.DATABASE_URL` in Node.js, `os.environ.pop('DATABASE_URL')` in55 Python). This limits the window during which the secret is visible in `/proc/environ`.56 However, this does not prevent inheritance by child processes started during the window,57 and the string may persist in the process heap even after unsetting.58594. **Audit CI/CD for env var logging.** Many CI systems (GitHub Actions, GitLab CI,60 CircleCI) mask secrets in logs but only if the secrets are registered as secret61 variables. Unregistered env vars containing secrets are logged in plaintext on every62 build. Audit all env vars in CI/CD for secrets that should be registered as63 masked/secret variables. Use CI secret scanning tools (gitleaks, truffleHog) on CI64 logs to detect accidental exposure.65665. **Use runtime secret injection.** Instead of setting env vars in Dockerfiles or67 docker-compose files (which bakes them into images or version-controlled files), inject68 secrets at runtime via: Kubernetes Secrets mounted as files, Vault Agent sidecar, cloud69 provider secret injection (AWS Secrets Manager + ECS task definitions, GCP Secret70 Manager + Cloud Run), or init containers that fetch secrets and write them to shared71 volumes. The secret should never exist in a layer that is persisted or version72 controlled.73746. **Never put secrets in Dockerfiles or docker-compose.yml.** `ENV DATABASE_PASSWORD=hunter2`75 in a Dockerfile bakes the secret into every image layer. Docker layer caching means76 the secret persists even if you delete it in a later layer (`RUN unset` does not remove77 it from the layer that set it). Use multi-stage builds with the `--secret` flag78 (BuildKit) or runtime injection. Similarly, `docker-compose.yml` with inline79 environment values is version-controlled -- secrets in it are secrets in git.8081## Details8283### Leakage Vector Inventory8485Every path through which environment variables can leak:8687- `/proc/<pid>/environ` -- readable by any process running as the same user on Linux88- `ps e` command -- shows environment of running processes89- `docker inspect <container>` -- displays all env vars in the container config90- `kubectl describe pod` -- shows env vars defined in the pod spec91- `heroku config` -- displays all config vars in plaintext92- CI/CD build logs -- many systems echo env vars on failure or in debug mode93- Crash dumps and core files -- include the full process environment94- Error reporting services -- Sentry, Bugsnag, Datadog APM capture environment95- Child process inheritance -- every spawned subprocess inherits all env vars96- Shell history -- if secrets are set via `export SECRET=value` on command line97- `.env` files committed to git -- even with `.gitignore`, mistakes happen98- Docker image layers -- `ENV` instructions are baked into the image99- Terraform state files -- store plaintext values of env vars set via `TF_VAR_*`100101### Safer Alternatives Comparison102103| Method | Leakage Risk | Rotation Support | Audit Trail | Complexity |104| --------------------- | ------------ | ---------------- | ----------- | ---------- |105| Environment variables | High | Manual | None | Low |106| Mounted files (tmpfs) | Low | Manual/Auto | OS-level | Low |107| Vault Agent injection | Low | Automatic | Full | Medium |108| K8s CSI Secret Store | Low | Auto-sync | K8s audit | Medium |109| SOPS-encrypted files | Medium | Manual | Git history | Low |110| Runtime API fetch | Low | Automatic | Full | Medium |111112### The Codecov Attack Case Study113114In January 2021, attackers modified Codecov's Bash Uploader script (used in CI/CD115pipelines across the industry) to exfiltrate all environment variables to an116attacker-controlled server. The attack persisted for two months before discovery. It117worked because CI pipelines routinely have dozens of secrets in environment variables --118AWS keys, database credentials, API tokens, signing keys -- and any script with process119access can read them all. Affected organizations included Twitch, Hashicorp, Confluent,120and hundreds of others.121122### 12-Factor App Reinterpretation123124The 12-factor app's "store config in environment" principle was about separating config125from code, not about security. The spirit of the principle (externalize configuration)126can be achieved more securely with mounted files, secret managers, or runtime injection.127The letter of the principle (use env vars specifically) has known security limitations128that its authors did not anticipate in 2011. Modern best practice: use env vars for129non-secret configuration (feature flags, log levels, service URLs) and mounted files or130vault injection for secrets.131132## Anti-Patterns1331341. **`.env` files committed to version control.** Even with `.gitignore`, `.env` files135 end up in git history through mistakes. Once committed, the secrets are in every clone136 forever (until the repository is purged with BFG or git-filter-branch). Use137 `.env.example` with placeholder values; actual `.env` files must never be committed.1381392. **Secrets in Docker Compose files.** `docker-compose.yml` is version-controlled.140 Secrets in it are secrets in git. Use `docker-compose.yml` with `env_file` pointing to141 a file not in version control, or use Docker secrets for swarm mode deployments.1421433. **CI/CD secrets as unmasked env vars.** If a CI secret is not registered as a masked144 variable, any `printenv` or debug log step exposes it in plaintext build logs that may145 be retained for months. Audit all CI/CD env vars and register secrets as masked/secret146 variables in the CI system's secret management.1471484. **Trusting `unset` to remove secrets.** Unsetting an env var removes it from the149 process environment listing but does not scrub it from memory (the string may persist150 in the heap until garbage collected or overwritten). It also does not affect child151 processes that already inherited the variable before the unset.1521535. **Using the same env var across all environments.** `DATABASE_URL` set to the154 production database URL in development environments means a development machine155 compromise leaks production credentials. Use environment-specific secret injection with156 separate credentials per environment. Development should use development-only157 credentials that cannot access production data.