npm 12 migration
npm 12 turned off install scripts. Deciding what to turn back on is the hard part.
What this covers
- What actually changed, and how it breaks
- Getting the list of pending scripts
- The setting that makes the list lie to you
- The only three questions that matter
- What the common packages actually do
- Answering all of it in one command
- Writing the allowlist: names or pinned versions
- Enforcing it in CI
- What none of this tells you
What actually changed
npm 12.0.0 shipped on 8 July 2026 and made install-time code execution opt-in.
preinstall, install and postinstall from your
dependencies no longer run unless the project explicitly allows them — and that
includes the implicit node-gyp rebuild npm performs for any package with a
binding.gyp. Git and remote-URL dependencies became opt-in in the same
release, behind --allow-git and --allow-remote.
The part that catches teams out is how quietly it fails. An unapproved
script is skipped, npm prints a warning, and the install succeeds. Your CI goes
green. What breaks is whatever depended on the script having run: a native addon that
never compiled, a browser binary that was never downloaded. That surfaces later, at
runtime, in a request — usually as cannot find module ...node.
A green build is not evidence that this went well. Read the install
warnings, or turn them into errors with strict-allow-scripts in CI, which
is covered below.
Step 1: get the list
Install your dependencies first, so npm can see which of them declare scripts, then ask for the pending ones. This is read-only and changes nothing:
npm install
npm approve-scripts --allow-scripts-pending
You get back the packages waiting on a decision and the command each one would run. On a typical application that is somewhere between three and a dozen entries, and it is the easy half of the job.
Step 2: check the setting that makes the list lie
If your project or CI sets ignore-scripts=true in .npmrc —
a very common hardening step from before npm 12 — it
silently bypasses the entire allowScripts system. Nothing runs even when
allowScripts is correctly filled in, and
--allow-scripts-pending reports nothing pending, because from npm's point of
view nothing is.
grep -r "ignore-scripts" .npmrc ~/.npmrc 2>/dev/null
If you find it, decide deliberately: keep ignore-scripts=true and accept that
no native module will ever build, or remove it and let allowScripts do the
job it was designed for. What you should not do is leave it in place and believe your
allowlist is being enforced.
Step 3: the only three questions that matter
Every other guide stops at "review them carefully." Here is what reviewing carefully actually means. For each pending package, an install script is worth worrying about if it does any of three things:
- Reaches the network. Downloading a prebuilt binary is normal and also exactly what an attacker does. The question is not whether but from where: the vendor's own release host, or somewhere you have never heard of.
- Reads environment variables or credentials. There is no legitimate
reason for an install script to read
process.envwholesale, or to touch~/.npmrc,~/.ssh, or cloud credential files. In CI those variables are your deploy keys. - Writes outside its own directory. A build step writes into the
package. Writing to your home directory, a shell profile, or
authorized_keysis persistence, not installation.
Everything else — compiling C++, checking whether a prebuilt binary already exists, printing a funding notice — is noise. If a script does none of the three, it does not deserve more of your attention than the thirty seconds it takes to confirm that.
What the common packages actually do
These are the eight packages most likely to appear in your pending list, reviewed at the versions current on 2 September 2026. No install script was executed to produce this table; each package's tarball was downloaded, verified against the registry's integrity hash, and read.
| Package | Install script | Verdict | What it means |
|---|---|---|---|
bufferutil |
node-gyp-build | approve | Local compile only. Nothing to decide. |
utf-8-validate |
node-gyp-build | approve | Local compile only. Nothing to decide. |
esbuild |
node install.js | review | Fetches over HTTPS and shells out to npm install. The one
package here where the evidence is unambiguous — see below. |
better-sqlite3 |
prebuild-install || node-gyp rebuild | review | Reads like a compile, but the first branch downloads a prebuilt binary. |
bcrypt |
node-pre-gyp install --fallback-to-build | review | Same shape: download first, compile only as a fallback. |
sharp |
node install/check.js || npm run build | review | Checks for an existing binary, then builds. No suspicious pattern in its own code. |
puppeteer |
node install.mjs | review | Downloads a browser, but through a dependency — nothing matches in puppeteer's own tarball. See the limits section. |
@swc/core |
node postinstall.js | review | Nothing matched. Unrecognised rather than suspicious. |
Two of eight are decidable automatically. That is the honest number, and it is the reason "just review them" is unhelpful advice: most real packages land in the middle, and what you need is not a verdict but the specific thing the script does, so that a thirty-second judgement replaces an hour of tarball archaeology.
Here is what that looks like for the one case with hard evidence:
REVIEW esbuild@0.28.2
postinstall: node install.js
network_and_shell: Code combines network access with shell execution.
Evidence install.js:147: function fetch(url) {
... https.get(url, (res) => {
Evidence install.js:187: child_process.execSync(
`npm install --loglevel=error ... ${pkg}@${packageJSON.version}`)
Now the decision is easy and it is yours: esbuild downloads its platform binary and shells out to npm to install it. That is documented, expected behaviour for esbuild, and you can approve it knowing exactly what you approved.
Answering all of it in one command
Reading eight tarballs by hand is the reason people rubber-stamp allowlists. This does the reading:
npx npx-vibe approve-scripts
It reads package-lock.json for every dependency npm would let run an install
script, downloads and integrity-checks each tarball, reads the script and the files it
reaches, and returns approve, review, or
deny with the source line behind each one. Nothing is executed.
It reads the lockfile directly rather than shelling out to npm, so it works on npm 10 and 11 as well as 12 — you can settle the list before you upgrade, instead of after your CI starts warning.
Step 4: write the allowlist
npm records the decision in package.json as an object keyed by package name
or by exact name@version:
{
"allowScripts": {
"bufferutil": true,
"utf-8-validate": true,
"esbuild": true,
"some-package": false
}
}
Prefer bare names. A pinned sharp@0.34.5 entry stops
applying the moment you bump the dependency, and you will be re-approving on every
upgrade. Pin only where you genuinely want a fresh decision each time.
To have the unambiguous decisions written for you:
npx npx-vibe approve-scripts --write
This writes only the packages it could decide: true for approve,
false for deny. Anything needing review is deliberately left
out. Auto-approving the ambiguous middle would recreate exactly the rubber stamp
the allowlist exists to replace. Commit the result so your machine and your CI agree.
Step 5: enforce it in CI
On a developer machine a skipped script is a warning. In CI it should be a failure, or you will ship an application whose native modules never built:
npm ci --strict-allow-scripts
Do not turn that on locally. A newly added dependency with an install script would fail your install before you have had the chance to review it.
To catch an unreviewed script at the point it enters the lockfile — in the pull request, rather than in a deploy — add the review to your workflow:
- uses: Devrajsinh-Jhala/NPM-Vibe-check@v2
with:
command: approve-scripts
fail-on: caution
With fail-on: caution the job fails when any dependency requests script
execution that nobody has decided on yet. Annotations land on the pull request and a
summary table on the job.
What none of this tells you
Being straight about the edges, because a security tool that oversells itself is worse than none:
-
Behaviour that lives in a dependency is invisible here. In the table
above,
bcryptandpuppeteerboth reach the network during install — but throughnode-pre-gypand@puppeteer/browsers, not through code in their own tarballs. "Nothing matched" is accurate about the package and incomplete about the effect. - Only part of each file is read. Files are read up to 64 KiB, and selection follows the install hooks, bin entry points, and the imports reachable from them. A payload placed elsewhere in a large bundle is not examined.
- Approve is not a safety certificate. It means the script does nothing but invoke a local build toolchain. That is a narrow, checkable claim, not a promise about the package as a whole.
- The allowlist is per-package, not per-version, unless you pin. A bare name keeps applying after an upgrade, which is convenient and is also how a compromised release inherits your earlier approval. Re-run the review when a lockfile changes.
Quick reference
| Task | Command |
|---|---|
| See what is pending | npm approve-scripts --allow-scripts-pending |
| Find out what those scripts do | npx npx-vibe approve-scripts |
| Write the decidable ones | npx npx-vibe approve-scripts --write |
| Approve one package, any version | npm approve-scripts <pkg> --no-allow-scripts-pin |
| Build the native modules after approving | npm rebuild <pkg> |
| Fail CI on a skipped script | npm ci --strict-allow-scripts |
| Check the trap | grep -r "ignore-scripts" .npmrc |
Start with the list you actually have
No install script is executed, nothing is written unless you pass --write,
and it works on npm 10, 11 and 12.
npx npx-vibe approve-scripts