Skip to content
shellhacks.dev
Security

How to Use Environment Variables for Config Without Leaks

Where environment variables leak secrets: child processes, /proc, logs, crash dumps, Docker inspect and CI output, and safer ways to pass configuration.

6 min read
Summarize in AI
Illustration for "How to Use Environment Variables for Config Without Leaks": a terminal session, environment variables, a magnifier over matching lines.

The twelve-factor advice to put configuration in the environment is good for ports and feature flags and uncomfortable for passwords. The environment is inherited, printed and dumped by many tools, and nobody reviews all of them. This is not a reason to avoid it everywhere; it is a reason to know where the leaks are. It connects to env and printenv and secrets in CI logs. The commands assume Linux 6.x and Docker 27.

Inherited by every child

Shell
export API_TOKEN=change-me
bash -c 'python3 -c "import os; print(os.environ.get(\"API_TOKEN\"))"'
bash -c 'ls /nonexistent; env | grep -c API_TOKEN'
Text
change-me
ls: cannot access '/nonexistent': No such file or directory
1

Any program you start receives the secret, including ones that do not need it: ls, an editor, a subprocess spawned by a library. A bug or malicious package in any of them can read and send it. Reduce the blast radius by starting children with only what they need: env -i PATH=/usr/bin:/bin HOME="$HOME" ./tool, or env -u API_TOKEN ./other.

Readable from /proc

Shell
tr '\0' '\n' < /proc/$$/environ | grep -c .
sudo tr '\0' '\n' < /proc/1234/environ | grep -E 'TOKEN|PASSWORD'
cat /proc/1234/cmdline | tr '\0' ' '

The environment at process start is readable by the same user and by root. Other users cannot read it (mode 400), which is protection against casual snooping, not against a compromised service account, which can read its own. Command-line arguments are worse: ps shows them to everyone on the machine by default, which is why secrets should never be arguments, a point also made in secrets in the process list.

Where they end up

Shell
docker inspect app --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass
kubectl get pod web-1 -o jsonpath='{.spec.containers[0].env}'

Container runtimes record the environment in their metadata, readable by anyone with access to the Docker socket or the Kubernetes API. Frameworks print the environment on debug and error pages; a core dump includes it; monitoring agents and APM tools may capture it; and a shell set -x prints expansions. A crash reporter sending "environment" to a third party is a real route out. Avoid placing secrets in image build arguments, since they stay in image history.

Safer ways to supply secrets

INI
[Service]
EnvironmentFile=/etc/app/app.env
LoadCredential=db_password:/etc/app/db_password
ExecStart=/usr/local/bin/app --db-password-file=%d/db_password
Shell
install -m 600 -o app -g app /dev/null /etc/app/db_password
printf '%s' "$DB_PASSWORD" > /etc/app/db_password
read -r password < "${CREDENTIALS_DIRECTORY}/db_password"

A file with mode 600 owned by the service user is readable only by it, not exposed in ps or inherited. systemd's LoadCredential= hands the file to the service in a private runtime directory (systemd 250 and later). Docker and Compose have secrets mounted as files under /run/secrets, and Kubernetes can mount Secrets as files. The program must read the file, and many support a _FILE convention such as POSTGRES_PASSWORD_FILE. A secret manager that issues short-lived credentials at start avoids long-lived values entirely.

The trade-off behind the twelve-factor advice

Putting configuration in the environment solves real problems. It separates settings from code, works in every language, needs no file format and is easy to vary per deployment. The same properties make it a poor fit for secrets. The environment is an implicit channel: every program in a process tree receives it without being asked, and there is no access control inside it. A file can have an owner and a mode. A variable has neither. The question for each value is therefore not whether the environment is good or bad, but what happens when this particular value is read by something that should not have it.

Classify values first. A log level or a region name is harmless wherever it appears. A database password, an API token or a signing key is not, and it deserves a channel with access control. Having the two groups clearly labelled in the documentation, or even in separate files, makes review simple and keeps people from treating a dangerous variable as casually as a harmless one.

How leaks actually happen

Attackers rarely read the environment directly. The more common route is a tool that prints it for a good reason. A framework's debug page lists all variables to help a developer, and the page is left enabled in production. A support bundle script collects env output to diagnose a customer issue and attaches it to a ticket. A crash handler serialises process state, including the environment, to a third-party service. A CI job runs printenv while debugging a failure. Each case is an accident by someone acting in good faith, which is why technical limits beat guidelines.

Child processes widen the exposure. A build tool that spawns a compiler, a linter and a test runner hands the full environment to each, along with every transitive dependency that executes code at install or run time. Starting the sensitive step with a cleaned environment limits this, and wrapping the tool in a small launcher that reads the secret from a file just before executing it keeps the value out of the parent shell entirely.

Files, sockets and short-lived credentials

A secret in a file with mode 600 gives you three properties the environment lacks: only the intended user can read it, it does not propagate to children, and its path is visible in configuration, so a reviewer sees where secrets come from. Mounting secrets as files in containers is the same idea. Applications that read a path from a variable, such as a _FILE suffix convention, give both flexibility and safety: the variable holds a location, not the secret itself.

Better still is not holding a long-lived secret at all. Cloud providers issue credentials to a workload based on its identity, and a sidecar or a library fetches a token that expires in an hour. A leaked token is a short incident. This requires some setup, but it replaces rotation, which people forget, with expiry, which happens automatically.

Verifying what you ship

Check the result instead of assuming it. For a container image, run docker history --no-trunc and docker inspect and read what they show; for a systemd service, systemctl show -p Environment lists what the unit sets. Search the running process with tr '\0' '\n' < /proc/PID/environ as the service user, and see exactly which secrets a compromise of that process would yield. Treat that list as the exposure, and shrink it until it holds only what the program needs to start.

Where this does not apply

For non-secret configuration (log level, port, region) the environment is fine and convenient. A throwaway local script with a test token is low risk. Windows and macOS have different inspection paths, though the inheritance issue is the same. If a secret has already appeared in a log or a dump, rotate it, as described in rotating credentials. See the twelve-factor config principle and the systemd credentials documentation.

Taggedvariables
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.