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.
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 12 | pnpm 11 | |
|---|---|---|
| Install scripts | allowScripts, off by default | allowBuilds map, off by default |
| Unreviewed scripts | warn (strict-allow-scripts false) | fail (strictDepBuilds true) |
| Release cooldown | min-release-age, default off | minimumReleaseAge, default 24h |
| Escape hatch | --dangerously-allow-all-scripts | dangerouslyAllowAllBuilds |
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.
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.