npm 12 became the latest npm in July 2026. Its headline change is a security default. Dependency lifecycle scripts (preinstall, install, postinstall) no longer run unless the project approves them. That includes the build npm used to start on its own for any package that ships a binding.gyp file. GitHub’s release announcement puts it in one line:

Dependency lifecycle scripts (i.e., preinstall, install, postinstall) and implicit node-gyp builds no longer run unless explicitly allowed.

I think it is a good default. An install script lets any package in your dependency tree run code on the machine during npm install, and that is the path npm is closing. But the vendor posts describe the policy, and I wanted to know what it looks like from the pipeline side. When a native dependency is skipped, does anything fail? Which packages actually break? Which check catches it before the first request in production does?

So I measured it with a local registry, two stand-in packages, npm 11.21.1, npm 12.0.0 and npm 12.2.1 (the latest tag when I ran this on 2026-10-23), and four real packages from the public registry. The short version:

  1. npm 12 exits 0. Every module that needed its install script is missing, and the first require() fails.
  2. npm ci --silent prints nothing at all. The only signal was a warning, and --silent drops it.
  3. npm 11.21.1 prints almost the same warning, but still runs the scripts. A team that learned to ignore it on npm 11 is likely to ignore it on npm 12 too.
  4. The read-only review command exits 0, and on a clean checkout it says there is nothing to review.
  5. Approvals are pinned to a version by default. Bump the dependency and it is blocked again, still with exit 0.
  6. --strict-allow-scripts turns an unreviewed script into exit 1. That is the gate, together with a require() smoke test. It already exists on npm 11.21.1, so you can add it before you upgrade.
  7. Of four real native packages, only sqlite3 broke. The warning lists packages whose scripts were skipped, not packages that are broken.

Code and raw logs: yopa-experiments experiment/npm12-skipped-install-scripts-20261023 (final commit 741a425).

The words, briefly

TermMeaning here
Lifecycle scriptA command in a package’s package.json scripts that npm runs at install time (preinstall, install, postinstall)
Native add-onA Node module written in C or C++ and compiled to a .node file that require() loads
node-gypThe tool npm uses to compile native add-ons. If a package has a binding.gyp and no install script, npm used to run node-gyp rebuild for it on its own
Prebuilt binaryA .node file built ahead of time, either shipped inside the package (prebuilds/) or downloaded by an install script
allowScriptsA new field in the root package.json that lists which dependencies may run install scripts

The setup

A small read-only registry on 127.0.0.1 serves two stand-in packages. Both are shaped like real ones, and neither does anything outside its own folder:

  • nativedemo: fourteen lines of C and a binding.gyp, with no install script. Its index.js is module.exports = require('./build/Release/nativedemo.node'), so it only works if npm compiled it.
  • postdemo: a postinstall that writes generated.js, which index.js requires. This is the shape of ORM clients and tools that download a binary at install time.
1
2
{ "name": "postdemo", "version": "1.0.0", "main": "index.js",
  "scripts": { "postinstall": "node gen.js" } }

After every install I ran a smoke test: require() each package and call it. The registry serves only these tarballs. The real-package runs used the public npm registry. Nothing was published, and no cloud account was involved.

What the pipeline sees

What I rannpm versionExit codeWould CI fail?Do the two modules load afterwards?
Plain npm install, the old baseline11.21.10NoYes, both load
Plain npm install after the upgrade12.2.10NoNo, both fail: Cannot find module './build/Release/nativedemo.node', Cannot find module './generated.js'
npm ci --silent with a lockfile written by npm 11 (the usual CI job)12.2.10NoNo, both fail, and the log is empty
npm install --strict-allow-scripts12.2.11YesNothing is installed; the error is ESTRICTALLOWSCRIPTS
Plain npm install with strict-allow-scripts=true in .npmrc12.0.01YesNothing is installed; same error on the first npm 12 release

Takeaway: on npm 12 the install “succeeds” with both modules missing, and only strict mode turns that into a failed build.1

The third row is the common CI shape: a lockfile from before the npm upgrade, npm ci, and a quiet log level. Its entire log is the command line and the exit code:

1
2
$ npm ci --silent --registry http://127.0.0.1:4873/ --no-audit --no-fund --cache $TMP/cache12
[exit 0]

Without --silent, npm 12 does tell you, at the end of the output:

1
2
3
4
added 2 packages in 206ms
npm warn install-scripts 2 packages had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   nativedemo@1.0.0 (install: node-gyp rebuild)
npm warn install-scripts   postdemo@1.0.0 (postinstall: node gen.js)

Now compare what npm 11.21.1 printed for the same two packages. It printed this while it was still running both scripts:

1
2
3
4
added 2 packages in 475ms
npm warn install-scripts 2 packages have install scripts not yet covered by allowScripts:
npm warn install-scripts   nativedemo@1.0.0 (install: node-gyp rebuild)
npm warn install-scripts   postdemo@1.0.0 (postinstall: node gen.js)

“have install scripts not yet covered” became “had install scripts blocked”. Same prefix, same list, same position after the summary line. npm 11.16+ shows the warning so teams can get ready before the upgrade. But if your logs have shown it for weeks with nothing broken, you have learned to skip it. The day the toolchain moves to npm 12, it looks the same and means the opposite.

This is the same trap I hit with ECS deployments that report “completed” with nothing running. The tool reports what it did, and what it did is not what you needed.

The review command is not a gate

The docs point you to a read-only listing: npm install-scripts ls, or its alias npm approve-scripts --allow-scripts-pending. It is useful at a desk. I wanted to know whether it could fail a build.

When I ran npm install-scripts lsWhat it printedExit codeWould CI fail?
Right after npm install, with 2 packages not yet approvedBoth packages0No. It found problems and still reported success
A fresh checkout, before anything is installed (how every CI job starts)No packages with unreviewed install scripts.0No. It found nothing, because nothing was installed yet

Takeaway: this command never fails a build, so npm install-scripts ls && ... does not protect you.

It exits 0 when there is something to review, so && will not stop on it. And it reads what is installed in node_modules, not the lockfile. On the fresh checkout a CI job starts from, it tells you there is nothing to review. The lockfile does carry the signal: npm 11’s lockfile for this project had "hasInstallScript": true on both packages. But nothing in npm turns that into a failure unless you use strict mode.

Approvals are pinned, so a version bump blocks it again

The fix the docs describe works. I measured it:

1
2
npm install-scripts approve nativedemo postdemo
npm rebuild

approve wrote this into package.json:

1
"allowScripts": { "nativedemo@1.0.0": true, "postdemo@1.0.0": true }

npm rebuild ran both scripts and both modules loaded. A fresh clone with that package.json committed passed npm ci and loaded both too.

Then I did what a dependency bot does and bumped nativedemo to 1.0.1:

1
2
3
4
changed 1 package in 220ms
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   nativedemo@1.0.1 (install: node-gyp rebuild)
[exit 0]

allowScripts still said nativedemo@1.0.0. The new version was blocked, the install exited 0, and require('nativedemo') failed again. Pinned approvals are the safer default, because a new version of a package gets reviewed again. But it means every Dependabot or Renovate PR that bumps a native dependency can pass npm ci and break at runtime, unless something in CI fails on unapproved scripts. (--no-allow-scripts-pin writes name-only approvals that cover future versions. That trades the review away, and I did not test it.)

Which real packages actually broke

The warning tells you which packages had scripts skipped. It does not tell you which of them needed those scripts. So I installed four real packages, with four different install shapes, on both npm versions, then loaded each one and called it once:

PackageHow it gets its binarynpm 12 warning?npm 12.2.1 smoke test
sqlite3@6.0.1install: prebuild-install -r napi || node-gyp rebuild downloads or builds ityesFAIL: Could not locate the bindings file
bcrypt@6.0.0ships prebuilds/ in the tarball; install: node-gyp-buildyesok
esbuild@0.28.2platform binary comes from an optional dependency (@esbuild/linux-x64); postinstall only checks ityesok
better-sqlite3@13.0.3ships prebuilds/, no install script, "gypfile": falsenook

All four worked on npm 11.21.1. On npm 12.2.1 three of the four printed the warning, and one of them was broken. After npm install-scripts approve sqlite3 and npm rebuild, sqlite3 loaded and opened an in-memory database.

So the honest reading of the warning is “these packages might need you”. This changes what you do about it:

  • Do not approve everything to make the warning go away. bcrypt and esbuild worked without their scripts, so approving them gives install-time code execution back for no benefit. npm install-scripts approve --all exists. Turning the default back off for every package is the opposite of why npm changed it.
  • Approve what your smoke test shows you need, and deny the rest. npm install-scripts deny <pkg> records an explicit false. Strict mode then stops flagging that package (I measured this below), and per the docs approve --all will not turn it back on (I did not test that part).

This was one platform (Linux x86_64, Node 24.22.0). A package whose prebuilds/ folder does not cover your OS, CPU, or libc will fall back to its install script and can break where it worked for me.

The gate I would add

Two real steps in CI, after a read-only preflight. The first fails on any dependency with unreviewed install scripts. The second proves that the native modules you depend on load:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
# usage: ci-gate.sh <native-module> [<native-module> ...]   e.g. ci-gate.sh sqlite3
set -euo pipefail
[ "$#" -ge 1 ] || { echo "usage: $0 <native-module>..." >&2; exit 2; }
# read-only preflight: npm ci needs a lockfile; print the npm version in the log
test -f package-lock.json || { echo "no package-lock.json; refusing to install" >&2; exit 1; }
echo "npm $(npm --version)"
npm ci --strict-allow-scripts
node -e 'for (const m of process.argv.slice(1)) require(m); console.log("loaded:", process.argv.slice(1).join(" "))' "$@"

Run it as ci-gate.sh sqlite3, with your own native dependencies as the arguments. npm ci replaces node_modules without updating package.json or the lockfile. It can also write npm’s cache and run lifecycle scripts allowed by your configuration. Those scripts run with your user’s permissions; their writes are not confined to the checkout. Run the gate in a disposable CI environment with only the files and credentials the build needs.

When the strict npm install failed in the table above, it left no node_modules folder behind. I ran this exact file (scripts/ci-gate.sh in the experiment repo) against the stand-ins in three situations: no approvals, approvals committed, and no lockfile. For the other three rows I ran only the strict npm ci step:

SituationWhat ranExit codeDoes the build stop?
npm 12.2.1, lockfile but no approvals in package.jsonthe whole gate1Yes. ESTRICTALLOWSCRIPTS lists both packages
npm 12.2.1, allowScripts committed for both packagesthe whole gate0No, and that is right: it prints loaded: nativedemo postdemo
npm 12.2.1, no package-lock.json in the repothe whole gate1Yes. The preflight refuses before anything is installed
npm 12.2.1, strict-allow-scripts=true in the project .npmrc, plain npm cistrict npm ci only1Yes
npm 11.21.1, npm ci --strict-allow-scriptsstrict npm ci only1Yes, already on npm 11
npm 12.2.1, nativedemo approved, postdemo denied with falsestrict npm ci only0No. No warning either, yet require('postdemo') fails

Takeaway: strict mode stops the build whenever a script is unreviewed, even on npm 11, but a package you explicitly denied gets through, and only the require() line catches it.

The useful surprise is the npm 11.21.1 row. The strict flag is already on npm 11.21.1, so you can turn it on now, sort out approvals while npm 11 still runs the scripts, and upgrade npm later. (npm 11.21.1’s own warning already points to npm install-scripts approve. I did not run approve on npm 11.) The .npmrc form is the one to commit, so local installs and CI follow the same rule.

The require() line matters even with strict mode on, and the last row shows why. Strict mode checks your policy. A package you denied passes it silently, and the install exits 0. If that package did need its script, only loading it will tell you. The same goes for a prebuilt binary that does not exist for the platform you deploy to.

What I did not measure

  • macOS, Windows, Alpine/musl, or ARM. The real-package results depend on which prebuilt binaries exist for the platform.
  • Workspaces and install-strategy=linked. npm 12.2.1’s changelog has a fix for allowScripts keys in linked installs, so behavior there has changed recently.
  • pnpm and Yarn, which have their own script policies.
  • Docker layer caches that restore an old node_modules. Those can hide the change until the cache is invalidated.
  • Earlier npm 11 releases. Only 11.21.1 was tested.
  • --no-allow-scripts-pin, --dangerously-allow-all-scripts, and whether approve --all respects an earlier deny.

What to do

  • Before you upgrade: npm says the warnings exist from 11.16.0. I only tested 11.21.1, where --strict-allow-scripts already fails the install. On that version or later, add strict-allow-scripts=true to the project .npmrc, run npm ci, and see which packages it lists. Approve only the ones whose require() fails without the script, and deny the rest. Commit package.json.
  • In CI: keep strict mode on, and add a require() smoke test for each native dependency. Do not rely on the warning or on npm install-scripts ls to catch this. The first can be silenced, and the second exits 0.
  • For dependency-bot PRs: expect native packages to fail strict mode on every version bump, because approvals are pinned. That failure is the review step working. Re-approve with npm install-scripts approve <pkg> once you have read the diff.

For the Python side of keeping dependency updates reviewable, see Safely Update Python Project Dependencies.

References


  1. The experiment repo names these runs with short case IDs (c01 to c09 for installs, g1 to g7 for the gate). They only matter if you want to match a row to its raw log there. ↩︎