npm fixed install scripts in July, and 24 Node releases later yours still runs them

  • Yunik Maharjan
  • 14 min read

npm 12.0.0 stopped running your dependencies’ install scripts on 8 July 2026. Node.js has shipped 24 releases dated that day or later, and not one of them carries npm 12.

Installing a package executes code. npm runs preinstall, install and postinstall from every package in the tree, before anything is imported. npm 12 blocks dependency install scripts by default; no Node.js release bundles npm 12, so the default on your machine is still the old one. Upgrade npm itself, then approve the handful that need it.

Every number below is quoted from a primary document with a link, or divided on this page from numbers already printed on it. Nothing here was measured on a build machine, ours or anyone’s.

Does npm run postinstall scripts from dependencies?

On the npm that is almost certainly on your machine, yes — preinstall and install as well, from every package in the resolved tree, before one line of your own code is imported. npm’s accepted RFC 0054 scopes it to those three hooks “when they are defined by dependencies (not the root project)”, auto-detected binding.gyp files “(which trigger implicit node-gyp rebuild)”, and prepare for non-registry sources only — git, file and link dependencies. Your own scripts always run. npm 12 reversed that default in July.

Hold on to that prepare line. A dependency pulled from a git URL builds itself on install where the same package from the registry does not, and that boundary returns at pip.

The order is on the npm CLI’s scripts page, under Life Cycle Operation Order:

These run on a bare npm install in a local project (no package arguments).

Underneath runs the list: preinstall, install, postinstall, prepublish, preprepare, prepare, postprepare, the first marked as firing before dependencies are installed. npm ci gets the identical order, after it validates the lockfile. And npm writes an install hook for a publisher who wrote none: given a binding.gyp in the root and no install or preinstall script, it defaults install to “compile using node-gyp via node-gyp rebuild”.

RFC 0054 calls them a threat class of their own:

Install scripts run without any require() or import. They fire just by being in the dependency tree. This means they can attack systems that never intend to run the package’s code at all: frontend-only projects, build machines, CI pipelines.

npm 12 blocks install scripts by default, and no Node release ships it

npm changed the default on 8 July 2026, and 113 days later the runtime that installs npm for most people has not picked it up. The npm/cli v12.0.0 release breaking-changes list reads:

Dependency lifecycle scripts are now blocked by default unless allowed by the root package’s allowScripts policy. After installing, run npm install-scripts approve to record approvals and npm rebuild to execute newly approved scripts.

The 113 days are 23 left in July, 31 in August, 30 in September and 29 up to today: 23 + 31 + 30 + 29 = 113, or 16.1 weeks at 113 / 7. Node.js releases dated 8 July 2026 or later, from nodejs.org/dist/index.json on 29 October 2026, by the npm each bundles:

Bundled npm Releases
11.20.0 5
11.19.1 6
11.19.0 5
11.18.0 1
11.17.0 3
11.16.0 1
10.9.9 2
10.9.8 1
12.x 0

5 + 6 + 5 + 1 + 3 + 1 + 2 + 1 = 24, and nothing in the whole index bundles an npm starting with a twelve.

Second route, independent of that count: the newest release on each active line. Node v26.13.0 of 28 October 2026 bundles npm 11.20.0, v24.22.2 of 27 October bundles 11.19.1, v22.23.4 of 20 October bundles 10.9.9. Three lines, no npm 12. The nodejs/Release schedule made Node 26 the LTS on 28 October 2026, so the first of those is the current one.

Double the cadence and nothing moves. 113 / 24 = 4.7 days per release; halve that and the same window holds 48, with the count bundling npm 12 still zero, because the bundled npm is a property of the release line rather than of its rate. The obvious reading, that npm 12 is too new for these lines, is wrong: npm@12.2.1 declares engines of ^22.22.2 || ^24.15.0 || >=26.0.0, which all three satisfy. Nothing technical is in the way. What might be is how Node handles a semver-major npm bump inside a live release line, which I could not find written down in nodejs/Release. My reading, not a quotation. Derate the 113 days by 4x, to 28, and the recommendation doesn’t flip: the then-current Node LTS already bundled npm 11 on day one.

npm was last, not first, and its own RFC is wrong about Yarn. Under “npm is the last holdout”, RFC 0054 dates Bun and Deno to the day each learned to install npm packages, and pnpm to v10 — right on both, since the pnpm 10 release of 7 January 2025 leads its major changes with “Lifecycle scripts of dependencies are not executed during installation by default!”. Yarn is the row that fails. RFC 0054 says twice that Yarn got there in 2020: “Yarn was the first major package manager to default scripts off (v2, 2020).” The source disagrees. In Configuration.ts, enableScripts reads default: true at every tag from @yarnpkg/cli/2.4.3 through 4.13.0, and default: false first at 4.14.0 — whose release dates “Makes enableScripts: false the default” to 16 April 2026, 53 days before the RFC’s own accepted_at: 2026-06-08. Curl both tags yourself.

What package-lock.json integrity proves, and what it doesn’t

An integrity hash proves the tarball you received is the tarball that was recorded, and says nothing whatever about what that tarball does. npm’s package-lock.json documentation defines the field twice in its File Format section:

integrity: A sha512 or sha1 Standard Subresource Integrity string for the artifact that was unpacked in this location.

npm goes further than neutral, and the fact sits in the sixth of the scripts page’s ten sections. Under Environment, in the package.json vars subsection, the npm scripts documentation says that “for install scripts (preinstall, install, postinstall), npm sets these environment variables”, and names two: npm_package_resolved, “The resolved URL for the package”, and npm_package_integrity, “The integrity hash for the package”. The hash the lockfile just verified is handed to the install script as a parameter — an input to the code that runs next, not a check on it. Arborist sets both at lines 332 and 333 of rebuild.js, in the environment each lifecycle script inherits. Signing doesn’t close that gap either: npm’s provenance documentation says under Provenance limitations that established provenance “does not guarantee the package has no malicious code”.

Name matching has the same problem. The header comment on script-allowed.js says registry dependencies match “by the name+version parsed from the lockfile’s resolved URL, NOT by node.packageName / node.version”, because those getters read the tarball’s own manifest and are “therefore attacker-controlled”. RFC 0054 says why that matters: the registry “does not validate that a published tarball’s internal package.json matches the manifest the registry serves”.

Check what –ignore-scripts costs before you put it in .npmrc

--ignore-scripts is all-or-nothing: it turns off your own root scripts along with your dependencies’, and it silently skips the node-gyp rebuild npm injects for any package carrying a gyp file. The config reference gives it a default of false, then spends two paragraphs on the exceptions:

If true, npm does not run scripts specified in package.json files. Note that commands explicitly intended to run a particular script, such as npm start, npm stop, npm restart, npm test, and npm run will still run their intended script if ignore-scripts is set, but they will not run any pre- or post-scripts.

Setting ignore-scripts also disables .npm-extension execution, as if ignore-extension were set.

Read that again. A flag called ignore-scripts does not ignore the script npm test runs: it runs your test script and drops pretest and posttest. Put echo pretest ran in a pretest script, set ignore-scripts=true, run npm test, and the output settles it in under a minute.

npm’s own verdict is in RFC 0054, past the halfway mark, under the heading asking why not keep the flag as-is. The flag “disables scripts for every package including the root project”, which “makes it impractical for projects that need some packages to build (e.g., sharp for image processing) while blocking scripts from the rest of the dependency tree”.

The skipped build is not free to restore. node-gyp’s README asks for a working Python and a C or C++ toolchain: Xcode Command Line Tools on macOS, the Visual C++ build environment on Windows. The way back is npm-rebuild, which lists installing with --ignore-scripts and --no-bin-links “to explicitly choose which packages to build and/or link bins”.

Of everything here, --ignore-scripts is the control I would recommend least, and I don’t put it in a shared .npmrc. It works. But a control that holds until the first engineer adds a native addon, then fails in a way whose quickest fix is to delete it, is not one I would hand to a team of four.

Which packages still need install scripts in 2026?

Fewer than the argument against the change assumes, and sharp, the package npm’s own RFC uses as its worked example, is no longer one of them. Each row prints that package’s install-time scripts entry verbatim, from the npm registry on 29 October 2026. The sample is chosen: four of five are native addons.

Package Install-time hook
canvas@3.2.3 install: prebuild-install -r napi || node-gyp rebuild
sqlite3@6.0.1 install: the same string, character for character
bcrypt@6.0.0 install: node-gyp-build
esbuild@0.28.2 postinstall: node install.js
sharp@0.35.5 no install-time hook

bash

npm view sharp@0.35.5 scripts
npm view canvas@3.2.3 scripts
npm view esbuild@0.28.2 scripts

A double pipe is a fallback, not a sequence: node-gyp rebuild fires only when prebuild-install exits non-zero, so the rows that read like compilers mostly fetch a binary instead.

RFC 0054’s worked allowScripts example approves sharp. As of 0.35.5 that approves nothing: sharp has no preinstall, no install and no postinstall, and ships 25 optional dependencies of prebuilt platform packages instead, among them @img/sharp-linux-x64, with no scripts field. The first command above settles it, and the other four are one command each. esbuild is the counter-example — RFC 0054 names it first among the packages that moved to prebuilt binaries, and 0.28.2 still ships a postinstall. That migration is real and unfinished.

I’m not going to print a fraction. No primary source publishes the share of packages in a real tree that carry install scripts, and RFC 0054’s own Unresolved Questions admits the gap instead of filling it. Run npm install-scripts ls and read your own number. My tolerance is that an allowlist earns its keep while a full review costs less than a sprint-planning slot, and with no published distribution to compare yours against, the line I draw is this: below a handful of approvals it is a five-minute job and I would just do it, while past a few dozen the count is telling you about your dependency tree, not about the policy.

How to block npm install scripts by default, ranked by what each control buys

Upgrade npm before anything else, because nothing else moves the default. First, npm install -g npm@12, then commit a floor in engines or .npmrc so a colleague’s older npm can’t quietly opt the repository back out. If you’re still on Node 20, which v20.end in the nodejs/Release schedule closed on 30 April 2026, npm 12 is out of support there rather than out of reach: engine-strict is false by default, so the install goes through and npm complains afterwards. lib/cli/validate-engines.js builds the message npm ${npm} does not support Node.js ${node} for log.warn, and keeps the hard exit for a Node too old to parse npm at all. Move the runtime anyway, because the end-of-life cliff is your first problem.

Second, run npm install-scripts ls, which the npm install-scripts documentation calls “read-only: it lists every package whose install scripts are not yet covered by allowScripts, without modifying package.json”, then approve what has to build. Third, set strict-allow-scripts to true in CI, which npm says will “turn the install-script policy from a warning into a hard error”. Fourth, and only if you can’t move npm, pair --ignore-scripts with a selective npm rebuild. Fifth is nothing — where every repository that has not changed its npm sits.

json

{
  "allowScripts": {
    "canvas@3.2.3": true,
    "sharp": true,
    "@some-org/telemetry": false
  }
}

The map is tri-state: true permits, false blocks silently, an absent package is blocked with a warning, and npm reads it only from the root project. Pinned keys are the default, since allow-scripts-pin is true, so approving canvas@3.2.3 does not approve 3.2.4. That sharp line approves nothing, for the reason above.

Two quirks bite. --allow-scripts is an error exactly where you first reach for it, since npm restricts it to npm exec, npx and global installs: passing it “during a project-scoped npm install, ci, update, or rebuild is an error”. And --dangerously-allow-all-scripts runs every dependency install script “regardless of whether it was approved or denied”, which leaves a team that sets it worse off than on npm 11, where no denial existed to override. An install script is also not a CVE, so none of this reaches the triage queue that EPSS over CVSS is about. Writing the policy down once is an afternoon of software development work, not a programme.

pip, wheels, and the Python equivalent of –ignore-scripts

Python has no post-install hook at all, so the comparable risk sits in the build rather than the install. The PyPA wheel specification says installation “notionally consists of two phases: Unpack. … Spread”, and that a wheel “may be installed by simply unpacking into site-packages with the standard ‘unzip’ tool”. No publisher gets a hook in it.

Where Python does run publisher code is the source build. pip’s Build System Interface reference says that for source distributions “this responsibility is delegated to ‘build backends’”, so a source install executes the backend’s build_wheel hook, which is arbitrary Python. The control is --only-binary set to :all:, and pip states the price in the same entry: “Packages without binary distributions will fail to install when this option is used on them.”

And here is where that prepare line from the first section pays off. npm covers prepare only for non-registry sources, which is the boundary pip draws between a wheel and a source distribution. Source you build is where code runs.

What changes on your next npm install

Nothing, unless you change npm yourself.

The field that now decides whether third-party code runs on your build machine is documented in two command pages, a config reference and a changelog, and absent from the one page a developer opens to find out what goes in a package manifest. npm install-scripts and npm approve-scripts describe the same field through different read-only invocations, ls on one and --allow-scripts-pending on the other. RFC 0054 names only the second. allowScripts has no section and no anchor on npm 12’s package.json reference, and the string does not occur in that page’s source on the latest branch. The pull request titled “docs: document npm 12 install script blocking”, npm/cli#9838, changed one file — CHANGELOG.md, one line.

So the question isn’t whether npm’s new default is right. Bun and Deno blocked it from the day each learned to install npm packages, pnpm settled it on 7 January 2025, Yarn on 16 April 2026, npm on 8 July 2026. The question is whether the npm sitting on your build machine is the one that made the call, and npm --version answers it in about a second.

Tell us what’s broken.

Twenty minutes, no deck. We’ll tell you straight whether this is work we should be doing, and if it isn’t, we usually know who should.

We come back within one business day.