Why can a postinstall script read your production secret?
Short answer: because npm install runs package scripts as you, with your environment, and nothing separates "the machine that installs packages" from "the machine that holds secrets." Every npm worm since 2025 has relied on that. You cannot stop publishers from being compromised, but you can decide how much a compromised install can read.
Incident write-ups end with “rotate your credentials.” This piece asks the earlier question: why was there anything worth stealing where the code ran? It is an argument, backed by a demo you can run, with the limits stated.
What the worms steal is the same every time
- September 2025. CISA’s alert on the Shai-Hulud worm: after gaining access, the malware “scanned the environment for sensitive credentials,” targeting GitHub personal access tokens and API keys for AWS, Google Cloud and Azure, and then used the compromised developer’s npm access to publish infected versions of their other packages (CISA).
- August 2026. Per Sonatype, the malicious releases ran a
preinstallhook that, where scripts are allowed, fetched a payload that searched “local files, environment variables, running processes, cloud metadata services, Kubernetes service accounts, and Vault locations” for npm, GitHub, cloud, CI/CD, database, SSH, payment and messaging credentials (Sonatype).
The attacker does not need to know your system. They harvest whatever the install environment can reach, and what it can reach is usually a lot: a laptop with cloud logins, or a CI job with deploy keys.
See it for yourself
npm lifecycle scripts run during install. This harmless stand-in prints only the names of credential-looking environment variables, never values. Create a package with a postinstall script:
// evil-demo/report.js
const names = Object.keys(process.env).filter((k) => /KEY|TOKEN|SECRET|PASS|AWS|GCP/i.test(k));
console.log(`[evil-demo] a postinstall script can see ${names.length} credential-like variables: ${names.join(', ') || '(none)'}`);
with "scripts": { "postinstall": "node report.js" } in its package.json, packed (npm pack) and installed from the tarball into an app, with three fake secrets in the environment. We ran this on npm 11.12.1 (2026-11-01), in a clean environment:
$ env -i PATH="$PATH" DEPLOY_TOKEN=demo AWS_SECRET_ACCESS_KEY=demo DATABASE_PASSWORD=demo \
npm install --foreground-scripts
[evil-demo] a postinstall script can see 3 credential-like variables: AWS_SECRET_ACCESS_KEY, DEPLOY_TOKEN, DATABASE_PASSWORD
Two details matter. First, by default npm does not show that output: without --foreground-scripts the script still ran (we confirmed by having it write a marker file), but the install looked silent. Second, npm install --ignore-scripts prevented it from running at all. The script could equally have read ~/.npmrc, ~/.aws/credentials, SSH keys or the cloud metadata endpoint; the environment is just the easiest place to show it.
Four layers that shrink the loot
Think of an install as running untrusted code, and give it as little as possible. The layers, from cheapest, with what each one cannot do:
1. Don’t run scripts you don’t need
npm ci --ignore-scripts (or ignore-scripts=true in .npmrc) blocks the hook this family of attacks used. The cost: packages that genuinely need a build step (native modules) must be handled explicitly. Limit: it protects install time only. The package’s code still runs when your app imports it.
2. Delay new versions
npm 11.12.1 has a min-release-age setting, in days, so the dependency tree only includes versions published more than that long ago (npm config get min-release-age; it cannot be combined with before). That gives scanners and the registry time to catch a bad release. Limit: it is a delay, not a verdict, and it does nothing for a package you already depend on at an older, already-compromised version.
3. Keep the install environment poor
The part most teams skip. What is in the shell where npm install runs?
- CI: build jobs should not hold production credentials. GitHub’s docs describe the alternative to stored cloud secrets: with OpenID Connect, “workflows [can] exchange short-lived tokens directly from your cloud provider” instead of keeping a password or token as a repository secret (GitHub Docs). Split build (installs, no deploy rights) from deploy (credentials, no
npm installof arbitrary packages). - Laptops: a development machine with a long-lived production key in the shell profile is a standing target. Use per-task, short-lived credentials where the provider supports them.
- Scope: a stolen token that can read one service’s variables is a smaller incident than a stolen account key.
Where Dynv fits: secrets are delivered to the process that needs them at runtime, not left in a .env file or exported into the shell where packages get installed, and a service token reads only its own service. That removes the file-and-shell loot from the install step. It does not make a build safe if you also put production secrets in the CI environment yourself.
4. Contain what runs at runtime
A dependency also runs inside your service after install, and a service has to hold its own secrets to work. A malicious package loaded into a running process can read that process’s environment and memory, with or without a secrets manager. The remaining defences are limiting what a process may execute and connect to, and making rotation cheap. A process firewall that enforces this is coming soon; it is not available today, so nothing here relies on it. Rotation is available (rotation docs): see the first-hour playbook.
An audit you can do in ten minutes
Run these in the shell and CI job where installs happen. If the output surprises you, that is your attack surface:
env | grep -Ei 'KEY|TOKEN|SECRET|PASS|AWS|GCP' | cut -d= -f1 # names only
ls -la ~/.npmrc ~/.aws ~/.ssh 2>/dev/null
npm config get ignore-scripts min-release-age
For each name that shows up, ask whether installing a package needs it. Usually the answer is no.
What this does not solve
- Compromise of the package you trust and run. Layers 1 to 3 shrink what install-time code can take; code that runs inside your app is layer 4.
- Secrets you hand the build on purpose (a private registry token, say): scope them to read-only and rotate.
- Developers’ own machines. Policies protect CI; laptops need the same discipline.
- Detection. None of this tells you a package is malicious. Dynv’s vulnerability list includes OSV’s malicious-package records today as severity unknown, without an alert; a dedicated malicious-package alert is coming soon.
Facts last verified . Spotted an error? Tell us.