npm 12 blocks dependency install scripts by default and gives you four different ways to let one run again. Two of those ways reject each other outright, so what follows is the decision rule: which command inside a project, which flag for a global install, what actually lands in git, and the CI gate that catches what the review command quietly hides from you.

TL;DR: Inside a project, never pass --allow-scripts on the command line. Run npm approve-scripts --allow-scripts-pending to review, then npm approve-scripts <pkg> (or --all) to write the allowScripts field into package.json, and commit package.json. For a global install (-g) or npx there is no package.json to write to, so you must use the install-time --allow-scripts=<pkg> flag, the same flag that is forbidden inside a project. Gate CI with npm ci --strict-allow-scripts.

The confusion is worth naming precisely, because the two failures look similar and have opposite fixes. npm approve-scripts exits EGLOBAL when you run it for a global install (npm/cli #9463). --allow-scripts is refused inside a project install, with an error telling you to edit package.json instead. Same person, two terminals, two contradictory messages. If you have already hit the second one, the walkthrough lives in Fix npm --allow-scripts not allowed in project installs; the reason the field belongs in package.json rather than where most people reach for it is covered in npm allowScripts: the package.json schema, not .npmrc.

Which flag, in which scope?

You want toCommandScopeMutates?What it touches
See what needs approval (read-only)npm approve-scripts --allow-scripts-pending or npm install-scripts lsprojectnonothing
Approve one packagenpm approve-scripts <pkg> / npm install-scripts approve <pkg>projectyesallowScripts in package.json
Approve everything pendingnpm approve-scripts --allprojectyesallowScripts in package.json
Permanently deny a packagenpm deny-scripts <pkg> / npm install-scripts deny <pkg>projectyesallowScripts (name-only false)
Allow scripts for a global or npx installnpm i -g --allow-scripts=<pkg>globalnonothing persisted (per invocation)
Persist a global allowancenpm config set allow-scripts=<pkg> --location=userglobalyesyour user .npmrc
Enforce in CInpm ci --strict-allow-scriptsprojectnofails the build on gaps

The model to keep in your head: approve-scripts writes a committed allowlist into package.json, while --allow-scripts is a one-off install-time override for contexts that have no package.json to commit. Each one is rejected in the other's territory.

Install script was skippedGlobal install or npx?npm i -g --allow-scripts=pkgnpm approve-scripts --allow-scripts-pending(review only)npm approve-scripts pkgor --allCommit package.jsonCI: npm ci --strict-allow-scriptsnpm config set allow-scripts=pkg --location=userto persist per machine"global""project"

Prerequisites

  • npm 12.x (npm -v). The npm install-scripts command namespace and the strict-allow-scripts config are npm 12 features, shipped with the breaking changes the npm team published on June 9, 2026 (GitHub Changelog). On 11.16.x you get warnings only.
  • Node.js 22+ (bundled with npm 12).
  • A repo with a package.json and a package-lock.json for the project workflow, plus global install access for the -g case.

Step-by-step

1. Inventory what would run, without writing anything

npm approve-scripts --allow-scripts-pending

This lists every dependency whose install scripts are not yet covered by allowScripts and changes nothing on disk. npm install-scripts ls is the equivalent under the namespaced command. Run it first, every time. It is the audit output you paste into the PR that adds the allowlist, so a reviewer can see what you decided to trust and what you left out.

2. Approve only the packages you actually trust

# one at a time, the safe default
npm approve-scripts esbuild

# or approve everything pending in one shot
npm approve-scripts --all

Either form writes entries into the allowScripts object in package.json. Approvals are version-pinned by default (--allow-scripts-pin is true), producing "[email protected]": true. Setting --allow-scripts-pin=false gives you name-only entries that survive version bumps, which is more convenient and strictly weaker: you lose the property that a new version drops back into "pending" and gets re-reviewed. I keep pinning on and treat the churn as the feature, because a postinstall added in a patch release is exactly the event an allowlist exists to catch. That is the same fast-publish window that dependency cooldowns close from the release-timing side.

--all is fine on the first migration of a repo you already know. It is a bad habit after that, since it approves whatever happens to be pending rather than whatever you read.

3. Deny what should never run, and know the asymmetry

npm deny-scripts core-js

A denial is always written name-only as "core-js": false, whatever the pin setting says. That asymmetry is deliberate and useful: --all approves everything pending, but a false entry is sticky and is not overwritten by a later --all. Deny is the permanent "no" that a blanket approve cannot undo by accident.

4. Commit the one artifact this produces

The only thing that gets committed is the allowScripts field in package.json. Here is a real one, from the eslint project's npm 12 migration (eslint/eslint#21092):

{
  "allowScripts": {
    "core-js": false,
    "cypress": false,
    "re2": true,
    "yorkie": true
  }
}

Commit package.json. Do not adopt hand-editing as a workflow, let the commands maintain the field, but do review the diff the way you would review an IAM policy change: each true is a package you have granted arbitrary code execution on every developer laptop and every CI runner. The error message from a mis-run install names the two legal homes, allowScripts in package.json or .npmrc. Keep the project allowlist in package.json where it is committed, PR-reviewed, and shared, and reserve .npmrc for machine-local or global allowances that should not travel with the repo.

5. Gate CI so an unapproved script fails the build

npm ci --strict-allow-scripts

In a normal install npm skips unapproved scripts, prints a summary, and exits 0. A green local npm install therefore proves nothing about whether your allowlist is complete. --strict-allow-scripts (or npm config set strict-allow-scripts true) turns a skipped script into a hard failure, so CI catches the dependency that started shipping a postinstall between two commits instead of letting a native module go missing until it explodes inside a request at runtime.

Why does npm approve-scripts fail on a global install?

Install a global CLI on npm 12 and you get a warning that points you at the wrong command:

npm install -g some-cli
# npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to
#   review, or `npm approve-scripts <pkg>` to allow.

Follow that advice and you hit npm/cli #9463:

npm approve-scripts does not work for global installs

It exits with code EGLOBAL. The reason is structural rather than a bug in your invocation: approve-scripts writes to a project package.json, and a global install does not have one. The suggested command cannot work in that context, so treat the warning text as wrong there. The working fix is the install-time flag, optionally persisted to your user .npmrc:

npm install -g --allow-scripts=some-cli some-cli

# persist it for future global installs of that package
npm config set allow-scripts=some-cli --location=user

The mirror image is the project-scoped rejection of that same flag:

npm error --allow-scripts is not allowed in project-scoped installs.
Add the entries to the "allowScripts" field in package.json, or to .npmrc, instead.

One rule resolves both messages: project goes through approve-scripts and package.json, global and npx go through --allow-scripts at install time.

Verify it works

# 1. the allowlist is populated
node -p "Object.keys(require('./package.json').allowScripts || {}).length"

# 2. strict CI passes with nothing pending
npm ci --strict-allow-scripts && echo "OK: all scripts covered"

The first prints a non-zero count. The second exits 0 with no install scripts not covered line. If the second command fails while --allow-scripts-pending showed you nothing, that is not a mistake on your part, it is the pitfall below.

Common pitfalls

The review command hides optional deps that the strict check rejects. This is the sharpest trap in the feature, filed as npm/cli #9562. On Linux, npm approve-scripts --allow-scripts-pending does not list fsevents, because it is an optional dependency marked os: ["darwin"] and your platform skips scanning it. But npm ci --strict-allow-scripts validates the lockfile, sees fsevents in there, and fails:

npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts: [email protected]

Your review said "all clear" and CI said "rejected" for the same dependency tree. Approve the optional package explicitly even though the inventory never surfaced it (npm approve-scripts [email protected]), or run the inventory on the OS where that optional dependency actually installs. When the two disagree, the lockfile-based strict check is the source of truth and the platform-filtered review is the false negative.

Version pins go stale after upgrades. With pinning on, bumping [email protected] to 0.26.0 moves it back to pending, which is intended. Clear dead entries with npm install-scripts prune, and preview with --dry-run first.

A passing npm install is not proof of coverage. Non-strict installs skip and warn while still exiting 0. Only --strict-allow-scripts makes coverage a gate, so wire it into the pipeline rather than into your own shell aliases.

Wrap-up

The decision rule is short: approve-scripts plus package.json for projects, --allow-scripts for global and npx, deny-scripts for the permanent no, --strict-allow-scripts for CI. In that order, and with the pin left on.

Two follow-ups worth doing this week. Run npm ci --strict-allow-scripts in a fresh clone on the same OS as your CI runner, so platform-specific optional dependencies surface before they fail a real pipeline. Then check that a package you approved is one you would still approve at its current version, because install scripts are where a compromised release actually executes. For the full npm 12 migration timeline and the 11.16 warning window, see the npm v12 breaking-changes walkthrough; for the same problem in another ecosystem, cargo build.rs runs any code shows how it plays out in Rust.

FAQ

Do I commit package.json after npm approve-scripts? Yes. The allowScripts field lives in package.json, and committing it is the point: the allowlist gets reviewed in a PR and shared by the team. Let the commands maintain the field instead of hand-editing it, but read the diff like any other security change.

What is the difference between --allow-scripts-pending and approving a package? --allow-scripts-pending is read-only. It lists the packages whose install scripts are not covered by allowScripts and writes nothing. npm approve-scripts <pkg> mutates package.json to allow that package.

Why does npm approve-scripts fail on a global install? Because it writes to a project package.json and a global install (-g or npx) has none, so it exits EGLOBAL with "does not work for global installs" (npm/cli #9463). Use npm i -g --allow-scripts=<pkg> instead.

Why is --allow-scripts rejected inside my project? npm forbids the install-time flag in project scope and points you at the allowScripts field in package.json or at .npmrc. Inside a repo use npm approve-scripts; the CLI flag is for global and npx installs only.

My local install passed but CI failed on fsevents. Why? --allow-scripts-pending skips optional and off-platform dependencies, while npm ci --strict-allow-scripts validates the lockfile and sees them anyway (npm/cli #9562). Approve the optional package explicitly, or run the inventory on the same OS as your CI runner.

Sources