Back to glossary

Environment Variables

Environment variables are strings a process inherits at launch. For environment variables security, assume every dependency in that process can read them.

Also known as env vars, env var, process.env, os.environ, .env file, dotenv, environment variable secrets

Environment variables (env vars) are named strings that the operating system copies into a process at launch and that every child process inherits by default. Any code in the process can read all of them, including every dependency. Environment variables security therefore comes down to one question: what else runs with the same environment?

The habit of putting secrets there comes from configuration practice, not from any security property. The Twelve-Factor App methodology, which popularized it, states the rule plainly: "The twelve-factor app stores config in environment variables." Its goal was keeping credentials out of source code, and on that it was right. But an environment variable has no access control inside a process, no expiry, and no record of who read it. An infostealer hidden in an npm or PyPI package gets the entire environment with one expression: process.env in Node.js or os.environ in Python.

Our position: environment variables are a fine way to deliver configuration and a poor place for long-lived secrets in any process that runs third-party code. Deleting a variable after reading it does not fix this, because Linux keeps the launch-time copy readable at /proc/<pid>/environ. The controls that work keep the secret out of the process that installs or imports untrusted code, or make the secret worthless within minutes.

How do environment variables work?

#

Environment variables work by copying, not sharing. On Linux and macOS, a program starts through the execve system call, which takes three arguments: the program path, the argument list, and an array of KEY=value strings. The kernel copies those strings into the new process's memory, and the C library exposes them as a global array called environ. Node.js reads that array whenever code touches process.env. Python copies it into os.environ at startup and writes changes back with putenv and unsetenv.

Two design choices follow from this.

First, inheritance is the default. When a process starts a child, the runtime passes its own environment unless the caller supplies a different one. Your shell passes its environment to npm. npm passes it to every install script it runs. An install script passes it to the curl it runs.

Second, nothing controls access inside a process. The environment is one flat array in the process's memory. A library reading NODE_ENV uses the same API as a library reading AWS_SECRET_ACCESS_KEY, and the runtime cannot tell them apart.

Outside the process, Linux publishes each process's environment at /proc/<pid>/environ. Reading it requires the same kernel check as attaching a debugger, PTRACE_MODE_READ_FSCREDS. In practice, any process running as the same user can read it, as can root, and other users cannot. We started a Python process as user alice and read its environment as alice and as bob:

Shell Session
$ sudo -u alice env -i DB_PASSWORD=hunter2 python3 -c 'import time; time.sleep(60)' &
$ pid=$(pgrep -u alice -f time.sleep)
$ sudo -u alice cat /proc/$pid/environ | tr '\0' '\n'
DB_PASSWORD=hunter2
$ sudo -u bob cat /proc/$pid/environ
cat: /proc/550/environ: Permission denied

The diagram traces one install. Your shell (user alice) holds GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY, PATH, and HOME. Starting npm install copies all of them, and npm adds 31 more, including 26 npm_* variables and INIT_CWD. Starting a dependency's postinstall script copies the result again, so the script sees 35 variables including both secrets. Anything that script starts, such as curl, inherits the same copy. Linux exposes each process's launch copy at /proc/<pid>/environ, which any process running as alice can read and a process running as bob cannot.

Why deleting an environment variable does not erase it

#

Deleting an environment variable at runtime does not remove it from /proc/<pid>/environ. The proc(5) man page says the file holds "the initial environment that was set when the currently executing program was started." Later changes are not reflected.

One hardening pattern reads a secret at startup and then deletes it, so child processes will not inherit it. We tested that pattern in Node.js 22.23.4:

JavaScript
const fs = require('fs');
const { execSync } = require('child_process');
const fromProc = () => fs.readFileSync('/proc/self/environ', 'utf8')
  .split('\0').filter((kv) => kv.startsWith('API_KEY='));

delete process.env.API_KEY;
console.log('process.env.API_KEY:', process.env.API_KEY);
console.log('/proc/self/environ :', fromProc());
console.log('child process sees :', execSync('printenv API_KEY || echo "(unset)"').toString().trim());
Shell Session
$ API_KEY=sk_live_demo123 node scrub.js
process.env.API_KEY: undefined
/proc/self/environ : [ 'API_KEY=sk_live_demo123' ]
child process sees : (unset)

Python 3.13 behaves the same way. After del os.environ["API_KEY"], os.environ.get("API_KEY") returns None and /proc/self/environ still contains API_KEY=sk_live_demo123.

The cause is in the C library. Node's delete and Python's del both end in unsetenv, which removes the pointer from the environ array. The original bytes stay where the kernel wrote them at launch, and /proc/<pid>/environ reads those bytes. In a C test, overwriting the original string in place with memset changed the /proc output to API_KEY=xxxxxxxxxxxxxxx. Neither Node.js nor Python exposes a way to do that.

The diagram shows three steps. First, the kernel writes API_KEY=sk_live_demo123\0PATH=/usr/bin\0 into the new process at execve. Second, delete process.env.API_KEY calls unsetenv(), which drops the environ[] pointer to API_KEY and leaves the pointer to PATH. Third, process.env.API_KEY returns undefined, getenv("API_KEY") returns NULL, and child processes see it as unset, but /proc/self/environ still returns API_KEY=sk_live_demo123 because it reads the original bytes from step one. Only overwriting those bytes in place, as memset does in C, clears it.

So the delete-after-read pattern stops inheritance and nothing else. Any process running as the same user can still read the secret, and attackers already do. The payload in the Trivy GitHub Actions compromise read /proc/<pid>/environ from the GitHub Actions runner's own processes.

Is it safe to store secrets in environment variables?

#

Storing secrets in environment variables is safe against other users on the machine and unsafe against any code running as your user. Code running as your user can also read ~/.aws/credentials, so a file in your home directory is not much better. The environment adds leak paths that a file does not have.

The environment flows into every child process. We installed a local test package whose only content is a postinstall script, and printed the environment that script received under npm 10.9.9:

Shell Session
$ env -i PATH="$PATH" HOME="$HOME" GITHUB_TOKEN=ghp_fake AWS_SECRET_ACCESS_KEY=fake \
    npm install ../envdump-dep --foreground-scripts
visible: 35
non-npm: AWS_SECRET_ACCESS_KEY COLOR EDITOR GITHUB_TOKEN HOME INIT_CWD NODE PATH PWD
npm_config_userconfig = /home/alice/.npmrc
INIT_CWD = /home/alice/app

The script received both fake secrets, plus 31 variables npm added, 26 of them named npm_*. npm did not copy the registry token from .npmrc into the environment. It did hand the script the path to that file, and INIT_CWD, the project directory where .env files usually live.

The environment is readable from sibling processes. As shown above, /proc/<pid>/environ exposes the launch environment of every process the same user owns, including processes that deleted the secret.

Environment variables end up in build artifacts. Docker's build checks include a rule, SecretsUsedInArgOrEnv, because "setting secrets in a Dockerfile using ENV or ARG is insecure because they persist in the final image."

Environment variables get interpolated into config files. Many projects write //registry.npmjs.org/:_authToken=${NPM_TOKEN} in .npmrc. In June 2026, pnpm stopped expanding environment variables in registry credentials from project files, because a malicious repository's .npmrc could point a valid token at a different host. pnpm 11.10 then added a structured _auth setting that pnpm reads only from the environment or the global config.

An environment variable cannot change while the process runs. Kubernetes updates Secrets mounted as volumes "using an eventually-consistent approach." A Secret exposed as an environment variable keeps its launch value until the container restarts, so rotating it means restarting.

A .env file adds one more target. Libraries like dotenv load it into process.env at startup, but the file itself sits in plaintext in the project root, and attackers read it directly. The brand-squatted tanstack package did exactly that from a postinstall script.

How do supply chain attacks steal environment variables?

#

Supply chain attacks steal environment variables first because it is the cheapest read available. It needs no file paths, raises no permission errors, and in CI it holds the job's secrets. The table below covers incidents from the tj-actions/changed-files compromise in March 2025 to the LiteLLM compromise in October 2026, drawn from Socket's reports.

IncidentReportedWhat it took from the environment
tj-actions/changed-filesMar 17, 2025Printed CI secrets into workflow logs
Shai-Hulud, via @ctrl/tinycolorSept 15, 2025Looked for GITHUB_TOKEN, NPM_TOKEN, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY; checked npm tokens against the registry's whoami endpoint
aquasecurity/trivy-action tagsMar 20, 2026Read /proc/<pid>/environ of runner processes; on GitHub-hosted runners, used sudo to dump Runner.Worker memory
Unscoped tanstack 2.0.4 to 2.0.7Apr 29, 2026POSTed .env and .env.local, and in 2.0.6 every .env.* file in the install root
@tanstack/* (Mini Shai-Hulud)May 11, 2026Read GITHUB* variables, with other variable names decoded at runtime to defeat grep
node-ipc 9.1.6, 9.2.3, 12.0.1May 14, 2026Wrote all of process.env to envs.txt as KEY=value
LiteLLM 1.107.0Oct 21, 2026Collected nonempty environment variables and, on Linux, other readable processes' environments

Three patterns stand out.

First, collection is not selective. The node-ipc compromise copied every variable, and the Shai-Hulud payload ran TruffleHog, a legitimate secret scanner, over the host. No variable is safe because it has an unusual name.

Second, attackers moved from reading their own environment to reading other processes. The Trivy payload found runner processes by name (Runner.Worker, Runner.Listener, runsvc, run.sh) and read their /proc/<pid>/environ files. On GitHub-hosted runners it went further, dumping Runner.Worker memory and searching for the JSON pattern GitHub uses to store secrets, "isSecret":true. The LiteLLM payload read other readable processes' environments too. Scoping a secret to one workflow step does not help if a payload in another step can read the runner's memory.

Third, attackers hide which variables they read. The Mini Shai-Hulud payload in TanStack's npm packages decoded base64 ciphertext at runtime and used the result as the key for process.env[], so the variable names never appeared in the source. A scanner that greps for AWS_SECRET finds nothing.

That third pattern is why Socket's Environment variable access alert also flags dynamic access, where the variable name is computed at runtime. The alert is low severity on its own, because plenty of legitimate packages read NODE_ENV or HOME. It matters in combination: environment access plus network access plus install-time or obfuscated code is the shape of most rows in the table.

Environment variables vs. mounted files vs. secret managers

#

Environment variables, mounted secret files, runtime secret managers, and short-lived workload identity all deliver a credential to a process. They differ in who else can read the credential and for how long.

DeliveryInherited by child processesReadable by other same-user processesRotates without restartStops a malicious dependency in the process
Environment variableYes, by defaultYes, via /proc/<pid>/environNoNo
Mounted secret file (Kubernetes volume, /run/secrets)NoYes, through the fileYes in Kubernetes, eventually consistent (not with subPath)No
Secret manager fetched at runtimeNoOnly from process memory, or by reusing the manager credentialYesNo
Short-lived workload identity (OIDC, cloud instance roles)The token, no; the ability to request one, often yesYes, via the metadata endpoint or request variablesExpires on its ownNo, but stolen tokens expire

The last column is the uncomfortable one. The keyv and cacheable compromise payload read Vault tokens from /run/secrets/VAULT_TOKEN, the Kubernetes service account token from its mounted file, and AWS Secrets Manager across regions. Code inside your process can do anything your process can do, including call your secret manager's API.

The verdict: for long-lived secrets on servers, prefer mounted files or a runtime secret manager over environment variables. They close the incidental leaks: inheritance, image layers, /proc reads by sibling processes, and restarts to rotate. Against a malicious dependency, no delivery mechanism helps. Only two things do: the process that runs third-party code never holds the credential, or the credential expires before it is worth stealing.

Environment variables security controls, and what each misses

#

Install without secrets. Run dependency installation in a job whose environment holds no publish tokens or cloud keys, and give secrets only to the job that needs them. This is least privilege applied to the environment. On GitHub-hosted runners, separate jobs run on separate machines, which is a stronger boundary than separate steps:

YAML
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - run: npm ci          # no secrets in this job
      - run: npm test
  publish:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write        # only this job can request an OIDC token
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm publish

It misses dependencies that still run in the privileged job. The publish job installs packages too, and a payload that runs on require() or at Python startup does not need an install script.

Pass an explicit environment to child processes. Start tools with only the variables they need: env -i PATH="$PATH" HOME="$HOME" some-tool in a shell, or spawn(cmd, args, { env: { PATH: process.env.PATH } }) in Node.js. That keeps tokens out of npx tools, git hooks, and install scripts. It misses the parent's own /proc/<pid>/environ and any credential files the child can still open, such as ~/.npmrc.

Use runtime permissions where they exist. Deno denies environment access by default and can scope it to named variables. Under Deno 2.9.6, deno run --allow-env=API_KEY lets a script read API_KEY and throws on anything else:

Shell Session
$ AWS_SECRET_ACCESS_KEY=fake deno run --allow-env=API_KEY steal.ts
error: Uncaught (in promise) NotCapable: Requires env access to "AWS_SECRET_ACCESS_KEY", run again with the --allow-env flag

Deno also closes the /proc side door: reading /proc/self/environ failed even with --allow-read and asked for --allow-all. The runtime's future is uncertain, though. In October 2026, Deno's team announced it is joining Cloudflare and ending feature work, with 2.9.x LTS releases through October 2027. Treat Deno as proof the design works more than as a migration target.

Node.js has nothing equivalent. As of the Node.js v26.13.0 documentation, the Permission Model restricts the file system, network, child processes, workers, and native addons, but not environment variables. Under node --permission, Node.js 22.23.4 refused a file read with ERR_ACCESS_DENIED and returned process.env.API_KEY without complaint. A feature request to add --allow-env to Node.js is open. Python has no equivalent.

Make the credential short-lived. Trusted publishing replaces a long-lived NPM_TOKEN in the environment with an OIDC token minted for one workflow run. npm v12 also scheduled restrictions on 2FA-bypass granular access tokens for early August 2026 and removal of their direct publishing for around January 2027. Trusted publishing still hands the job environment variables: GitHub delivers the ability to request an OIDC token as ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. It misses attacker code running inside the publishing job. In the TanStack compromise, attacker code extracted an OIDC token from the runner's memory and published through the project's trusted-publisher binding, and the malicious versions carried valid Sigstore provenance.

Look at what a package reads before it runs. pnpm 10 stopped running dependency lifecycle scripts by default in January 2025, and npm v12 did the same in July 2026. Script blocking removes the most common trigger. It does nothing for code that reads process.env on require(), as node-ipc did, or for Python startup hooks, as LiteLLM used. Inspecting malicious packages before install, including which environment variables they touch and whether they compute those names at runtime, covers the triggers that script blocking misses.

When something does run, rotate everything it could see. Treat every variable in the affected process's environment, and in the environments of every other process running as that user, as stolen. Revoke from a clean machine.

The environment is ambient authority: every process gets all of it, every child gets a copy, and nothing records who read what. Deno showed that a runtime can grant variables by name. Node.js and the package managers that start lifecycle scripts still pass the whole environment by default. Until that default flips to an allowlist, treat the environment of any process that runs third-party code as readable by that code.