Search nomadLab

npm v12 Blocks Install Scripts: The Allowlist Migration

Three defaults flipped in npm 12 and the failure mode is silent, not loud. Here is what breaks, how the approve-scripts allowlist works, and why npm still ships its cooldown turned off while pnpm turns it on.

Updated

Your Docker build worked yesterday. Today it pulls a newer Node image, npm 12 comes along for the ride, and sharp throws MODULE_NOT_FOUND at runtime with nothing useful in the install log. Or Prisma’s client was never generated. Or husky’s hooks quietly stopped installing and nobody noticed for a week.

npm 12.0.0 shipped on 8 July 2026 and it is the largest default-behavior change npm has made in years. Three things that used to just work are now opt-in: dependency install scripts, git dependencies, and remote URL dependencies. GitHub announced them on 9 June. The changes are overdue, and the migration still has edges the changelog does not cover. Versions and config defaults below were checked against the npm registry and the npm v12 config docs on 22 August 2026.

What changed, precisely

Three separate mechanisms that people keep collapsing into one.

allowScripts defaults to off. npm no longer runs preinstall, install, or postinstall from your dependencies, no longer runs prepare for git, file, and link dependencies, and skips the implicit node-gyp rebuild it fires for any package containing a binding.gyp even when that package declares no install script at all. Scripts in your own package.json still run. This is about dependency code.

--allow-git defaults to none. Its type is "all", "none", or "root", where root permits only the git dependencies declared in your own manifest and blocks transitive ones. --allow-remote defaults to none on the same three values, covering HTTPS tarball URLs. --allow-file and --allow-directory both still default to "all", so file: protocol dependencies and workspace links keep working.

The git one is worth understanding because the reasoning is not obvious. A git dependency can carry its own .npmrc, and an .npmrc can override the path to the git executable. That is arbitrary code execution during install, and --ignore-scripts never protected you from it.

All three have been warning since npm 11.16.0, 11.10.0, and 11.15.0 respectively, and the 11 line is still getting releases. If you are on 11.x you have a free dry run available right now, which is the most useful thing in this post.

The failure modes you will actually hit

Some announce themselves. Most do not, because npm’s default is skip-and-warn-but-succeed.

Native modules fail at runtime rather than at install. Anything with a binding.gyp, so better-sqlite3, node-pty, canvas, various bcrypt builds, installs “successfully” and then explodes on require with a MODULE_NOT_FOUND pointing at a .node binary that was never compiled. That error reads like a completely different problem. Packages that fetch prebuilt binaries in postinstall behave the same way.

Prisma, Playwright, and Puppeteer do real work in postinstall: client generation, browser downloads. Blocked means your app boots against a client that does not exist, or your end-to-end suite cannot find Chromium.

husky fails silently and stays silent. No build goes red. Git hooks just stop firing, and pre-commit checks quietly stop happening.

Transitive git dependencies are the nastiest, because you did not write the offending line. Some package four levels down pins a fork via git+https://, and resolution now fails with an error naming a package you have never heard of. --allow-git=root does not save you there; that is exactly the case it exists to block.

And if you already hardened CI with ignore-scripts=true, you are in a confusing spot: it takes precedence over the allowlist, unconditionally. You can build a correct allowlist, commit it, and have nothing run, with no signal that the allowlist is inert.

Migrating in about twenty minutes

Start on npm 11, not 12, because 11.16+ warns instead of blocking and you get to see the whole picture before anything breaks:

npm install -g npm@11
npm install
npm approve-scripts --allow-scripts-pending

That last command lists every package with a lifecycle script that has not been approved. It is read-only. Read the list; on a mature project it is longer than people expect, and it is a free audit of which dependencies want to execute code on your machine.

Then approve what you actually need:

npm approve-scripts esbuild sharp prisma

That writes an allowScripts entry into package.json. Approvals pin to the installed version by default, so a version bump reopens the prompt. --no-allow-scripts-pin approves by name for all future versions, which you should use sparingly. npm approve-scripts --all clears everything pending at once and turns a security feature into a formality; if you do it on a large monorepo, at least read the list first and follow up with npm deny-scripts on anything you did not want, since denials survive --all and future review prompts.

After approving, run npm rebuild. Approval does not retroactively execute anything.

Then commit the package.json change. That is the actual point: adding a dependency that executes install-time code becomes a visible diff that someone has to approve, instead of an invisible one.

One ordering trap. strict-allow-scripts defaults to false and, per the docs, turns “the install-script policy from a warning into a hard error.” You want it in CI eventually. You do not want it while migrating, because installing a new package with scripts becomes impossible: the package has to be present before you can approve it. Install without strict mode, approve, rebuild, then turn it on.

Fixing CI

The highest-value change is pinning npm explicitly instead of inheriting whatever the base image ships. Most of the pain comes from images that silently rolled from 11 to 12 under an unpinned tag.

FROM node:22-bookworm-slim
RUN npm install -g npm@12.0.2
COPY package.json package-lock.json ./
RUN npm ci

Put the policy in .npmrc rather than sprinkling flags across commands, so local and CI behave the same:

allow-git=root
allow-remote=none
strict-allow-scripts=true
min-release-age=7

allow-git=root is the sane default when you have a legitimate fork pinned in your own manifest: it permits what you declared and still blocks anything transitive.

Then clean up ignore-scripts. The allowlist is strictly better, being per-package, committed, and reviewable. Once it works, remove ignore-scripts=true and let allowScripts and strict-allow-scripts do the job. Keeping both means the allowlist does nothing, which is a landmine for whoever debugs it next.

--dangerously-allow-all-scripts exists, bypasses the mechanism entirely, and is named that way on purpose. Fine for a one-off local reproduction. In a committed CI config it is a permanent hole with a warning label, and the label does not help.

The line nobody sets: min-release-age

The install-script block closes the path where merely installing a dependency runs attacker code. It does nothing about a version that is malicious on import. For that, the cheapest control by a distance is a cooldown.

min-release-age refuses any version published fewer than N days ago. Its npm default is null, meaning off. pnpm’s equivalent, minimumReleaseAge, has defaulted to 1440 minutes since pnpm 11, and pnpm’s own docs give the reason: “In most cases, malicious releases are discovered and removed from the registry within an hour.”

npm 12pnpm 11
Install scriptsallowScripts, off by defaultallowBuilds map, off by default
Unreviewed scriptswarn (strict-allow-scripts false)fail (strictDepBuilds true)
Release cooldownmin-release-age, default offminimumReleaseAge, default 24h
Escape hatch--dangerously-allow-all-scriptsdangerouslyAllowAllBuilds

npm’s defaults let more silent breakage and more fresh packages through in the first month. Setting strict-allow-scripts=true and min-release-age puts you on pnpm’s footing without switching package managers, which is the honest answer to “should we just move to pnpm over this.” Move for the disk savings and the strict node_modules layout if you want; those reasons stand on their own. Do not do a package manager migration to land in the same place a two-line .npmrc gets you.

Why the defaults changed

Two incidents from this year explain it better than any threat model.

On 31 March 2026, an attacker used a hijacked maintainer account to publish two poisoned Axios versions carrying a remote access trojan in a postinstall script. They were live for hours. Axios does tens of millions of downloads a week. A cooldown would have blocked it outright; so would allowScripts being off.

On 11 May 2026, the TanStack compromise went through a path neither control touches. No npm token was stolen.

The TanStack attack chain. A pull_request_target workflow checked out and built fork code, which poisoned the pnpm store in the GitHub Actions cache. The next release workflow on main restored the poisoned cache entry, the attacker code read the runner worker's process memory to extract an OIDC token, and used that token to POST directly to the npm registry. The resulting packages carried valid SLSA provenance. No token was stolen. The pipeline minted one. 1. pull_request_target checks out the fork's PR-merge ref and builds it 2. that run poisons the pnpm store in the Actions cache 3. release.yml on main restores the poisoned entry 4. read /proc/<pid>/mem, dump the worker, lift the OIDC token 5. POST straight to registry.npmjs.org, skipping the publish step 42 packages 84 versions Six minutes, 19:20 to 19:26 UTC, and every artifact carried valid SLSA provenance.
Sigstore verified the build correctly. The build was the thing that got hijacked.

TanStack’s own postmortem names the structural problem: “OIDC trusted-publisher binding has no per-publish review. Once configured, any code path in the workflow can mint a publish-capable token.” Trusted publishing is still the right move over a long-lived NPM_TOKEN, and provenance is still worth turning on. Neither proves the pipeline was not compromised. Treat a green attestation as evidence of origin, not of safety.

Which puts the weight on CI hygiene: never run untrusted code in a job that holds secrets, scope tokens per package rather than per org, and treat the Actions cache as untrusted across the fork boundary. That is the layer that actually stopped nothing here, and the layer teams underinvest in.

Behavioral scanning catches a different slice of this, and it is a real layer rather than a replacement for the free structural controls. I went through that category, and the hallucinated-dependency problem that AI agents add to it, in the code security tools comparison. Buy the scanner if it fits; set the .npmrc first.

Run npm approve-scripts --allow-scripts-pending on your main repo today, before you upgrade anything. Whatever is on that list has been running arbitrary code on every developer machine and every CI runner you own, for as long as it has been in your tree.

Keep reading