Medium wave · 540–1600 kHz78 stn
1421kHz

npm install Runs Code You Never Read

A long brass conveyor of sealed wooden shipping crates rolling into a dark steampunk foundry, one crate cracked open mid-belt with glowing neon green clockwork tendrils already reaching out and threading into the surrounding copper pipework, the rest of the crates humming with faint teal light, fog and reflective iron floor

You audited the dependency. You read the README, checked the download count, maybe even skimmed the source on GitHub. Then you typed npm install, and before you had imported a single line of it, the package ran a program on your machine.

That is not a vulnerability. It is the documented behavior of lifecycle scripts, and npm has worked this way for its entire existence. Any package anywhere in your tree — not just the ones you chose, all of the transitive ones too — can declare a preinstall, install, or postinstall script, and npm will execute it with your user’s permissions, your environment variables, and your network.

The code you review is the code you import. The code that actually runs first is the code nobody reviews.


What an install script can reach

Here is the entire attack surface, in a package you have never heard of, four levels deep:

{
  "name": "some-transitive-helper",
  "version": "2.4.1",
  "scripts": {
    "postinstall": "node setup.js"
  }
}

setup.js runs during install. Not at build time, not at runtime, not when a function is called. At install. It gets:

  • Every environment variable in the shell or CI step that ran npm install — NPM_TOKEN, GITHUB_TOKEN, cloud credentials, deploy hooks.
  • The filesystem as your user — ~/.npmrc, ~/.ssh, ~/.aws, the .env file sitting in your project root.
  • The network, unrestricted, in the one step of your pipeline where outbound traffic to a registry looks completely normal.

This is not hypothetical. The 2021 hijack of ua-parser-js pushed versions whose preinstall script downloaded a cryptominer and a credential stealer. The 2025 Shai-Hulud worm went further: its postinstall payload harvested npm tokens from the machines it landed on and used them to publish poisoned versions of other packages those maintainers owned. It spread through install scripts because install scripts are the one place in the ecosystem where arbitrary code runs automatically, everywhere, with credentials in scope.


Why a static site is not a smaller target

The instinct is that a static site has nothing worth stealing. There is no database, no user table, no server. That instinct is wrong about where the valuables are.

The valuables are on the build machine. It is the one place in a static pipeline that holds deploy credentials, preview tokens, CMS API keys, and write access to the repository. And it runs npm ci on every single push — typically with the same environment that the build and deploy steps use, because nobody bothered to split them.

It is also the machine that produces the files you ship to every visitor. A script that runs during install can modify anything in node_modules before your bundler reads it. Your output is only as trustworthy as the least-reviewed package that got to execute code first. We already covered how every env var in a static build is public once it reaches the client; install scripts are how secrets leak without ever reaching the client at all.


Count what you are actually trusting

Before you change anything, find out how big the problem is. npm’s lockfile already records which packages declare install scripts — lockfile versions 2 and 3 set hasInstallScript: true on every such entry:

// list-install-scripts.mjs
import { readFileSync } from 'node:fs'

const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'))
const entries = Object.entries(lock.packages).filter(([path]) => path !== '')
const scripted = entries.filter(([, pkg]) => pkg.hasInstallScript)

console.log(`${entries.length} packages, ${scripted.length} with install scripts`)
for (const [path] of scripted) console.log(`  ${path}`)

Run it on this site’s repository and you get:

608 packages, 2 with install scripts
  node_modules/astro/node_modules/esbuild
  node_modules/fsevents

Six hundred and eight packages, and exactly two of them want to run code at install time. One is esbuild, finishing the setup of a native binary. The other is fsevents, a macOS-only file watcher that is an optional dependency and never even installs on a Linux CI runner.

That ratio is typical for a modern frontend project, and it is the entire argument. Running scripts is the default, but needing them is the exception. Keeping the default on means granting six hundred packages a capability that two of them use.

If you prefer to query the installed tree directly, npm query can do it too:

npm query ':attr(scripts, [postinstall])'

Turn it off, then allowlist

The fix is one line of config and a short list of exceptions.

npm

Put it in the project’s .npmrc so it applies to everyone, including CI:

ignore-scripts=true

Try the build before you allowlist anything. This site installs with npm ci --ignore-scripts and builds cleanly with zero scripts run — esbuild’s platform binary already arrived as an optional dependency, and its install step turned out to be optional too. Your tree may differ, and that is what the test is for.

If something does break, explicitly rebuild the packages you have decided to trust:

npm ci
npm rebuild esbuild --ignore-scripts=false

One sharp edge to know about: ignore-scripts also suppresses your own pre and post hooks. npm run build still runs build, but a prebuild script will silently stop firing. If you rely on those, call the steps explicitly — which is clearer anyway.

If you want the allowlist to live in package.json instead of in a CI command, @lavamoat/allow-scripts does exactly that: it refuses to run any install script that is not listed, and fails loudly when a new one appears.

pnpm

pnpm made this decision for you. Since pnpm 10, dependency lifecycle scripts do not run unless you approve them, and pnpm approve-builds walks you through the list and writes the allowlist into your config. If you are on pnpm and have been clicking through that prompt without reading it, that prompt is the security review.

Bun and Yarn

Bun ships the same default — dependency scripts are skipped unless the package is in trustedDependencies in your package.json. Yarn Berry can disable them globally with enableScripts: false in .yarnrc.yml and re-enable per package through dependenciesMeta.

The shape is identical across every package manager: off by default, named exceptions, and the list lives in version control where it can be reviewed like any other change.


The allowlist is the useful part

Turning scripts off is the easy half. The allowlist is where the value comes from, because it converts a silent capability into a diff.

When a dependency update adds a new install script — including the day a hijacked patch release adds one — the install either fails or skips it, and somebody has to decide whether to add a line to the allowlist. That decision shows up in a pull request. That is the review step that did not exist before.

It also changes what you tolerate. Native packages have been moving away from install scripts for years: the modern pattern is to publish prebuilt binaries as platform-specific optionalDependencies and let the package manager pick the right one, with no code executed at all. When a package still insists on compiling or downloading at install time, that is a real cost you are now forced to see — and sometimes a reason to pick a different package.


Split install from secrets in CI

Disabling scripts shrinks the surface. Scoping secrets removes what is left worth stealing.

Most pipelines hand every secret to every step because it is easier to set them once at the job level. Do not. The install step needs a registry and nothing else; the deploy step needs the deploy token and nothing else.

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
      - run: npm ci
      - run: npm rebuild esbuild --ignore-scripts=false
      - run: npm run build
        env:
          PUBLIC_SITE_URL: ${{ vars.PUBLIC_SITE_URL }}
      - run: npx wrangler pages deploy dist
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Secrets are attached to the one step that uses them. The job-level token is read-only. If something in node_modules does run during install, it finds an environment with nothing in it.

This is not complete isolation — code installed in step one can still be executed by step four, when the deploy tool loads. But it closes the easy path, where a payload simply reads process.env at install time and posts it somewhere. The attacks that actually spread at scale take the easy path.


What this does not fix

Be honest about the boundary. Blocking install scripts stops code that runs at install. It does nothing about malicious code in the package itself, which runs the moment your build imports it.

The September 2025 compromise of chalk, debug, and a dozen other widely used packages is the counterexample: the injected code lived in the published JavaScript and targeted browsers, not build machines. An allowlist would not have caught it. A lockfile, a delay before adopting fresh releases, and a smaller dependency tree would have helped more — which is the argument in the dependency-count audit.

So this is one layer, not the whole wall. It happens to be the cheapest layer available: one line of config, a two-item allowlist, and secrets moved down one level of indentation in a YAML file.


The takeaway

npm install is not a download. It is the execution of every install script in your dependency graph, with your credentials in scope, on the machine that builds everything you ship.

Count the packages that actually need that privilege. On a typical static site it is a handful out of hundreds. Set ignore-scripts=true, allowlist the handful by name, and keep your secrets out of the install step. Then the next hijacked patch release has to get past a pull request instead of a keystroke.