09/26/2026
npm 12 install scripts: allowScripts and min-release-age
npm 12 no longer runs a dependency's preinstall, install or postinstall script unless your project has approved it in allowScripts; approve one with npm install-scripts approve <pkg>. That default closes the door the 2025 npm worms walked through, and it pairs with a second control worth switching on the same day: min-release-age, a cooldown that refuses versions younger than a set number of days.
# .npmrc, committed next to package.json
min-release-age=7
npm install # scripts from dependencies are blocked, not run
npm install-scripts ls # what was blocked, and the exact command it wanted
npm install-scripts approve esbuild
npm rebuild esbuild # run the now-approved script once
Why install scripts were the target
The CISA alert of September 23, 2025 described a self-replicating worm, Shai-Hulud, in more than 500 npm packages. It stole developer credentials, including GitHub tokens and AWS, Google Cloud and Azure keys, from the machines that installed it, then used them to publish infected new versions of other packages those developers could publish.
The November wave, which Palo Alto Networks' Unit 42 report covers, moved to preinstall, so the payload ran before the install had even finished. In both cases nothing had to be imported or executed by your code. Typing npm install was enough.
What npm 12 changes
GitHub's changelog for npm v12, dated July 8, 2026, lists three new defaults:
- Dependency lifecycle scripts and implicit
node-gypbuilds do not run unless allowed. - Git dependencies, direct or transitive, are not resolved unless allowed (
allow-gitnow defaults tonone). - Dependencies from remote URLs, such as https tarballs, are not resolved unless allowed (
allow-remotedefaults tonone).
Your own project's scripts are unaffected: npm run build ran as usual under npm 12.1.0, and so did the root package's own postinstall.
What a blocked script looks like
Installing esbuild, which downloads its platform binary in a postinstall, prints a warning rather than an error, and the install exits 0:
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts esbuild@0.28.2 (postinstall: node install.js)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
Approving one package
npm install-scripts approve esbuild writes the approval into package.json, pinned to the version you reviewed:
{
"dependencies": { "esbuild": "^0.28.2" },
"allowScripts": { "esbuild@0.28.2": true }
}
Pinning is the default (--no-allow-scripts-pin writes a name-only entry). It means the next esbuild release is blocked again until somebody looks at it, which is the point: a worm publishes a new version of a package you already trust. npm approve-scripts is an alias for npm install-scripts approve, according to the npm install-scripts documentation.
The trap: a warning is not a failure
Because a blocked script only warns, a package that genuinely needs its build step installs "successfully" and then fails later, at require time or at the first call into a missing binary. In CI that shows up as a test failure far from the cause. It is the same shape as a CI gate that passes on the word FAILED: exit code 0 does not mean the step did what you needed.
Two habits cover it. Run npm install-scripts ls after any dependency change and before committing the lockfile. And treat a new entry in allowScripts as a code-review item, the same as the dependency itself, rather than something to approve with --all to make the warning go away.
Add a release-age cooldown
pnpm's documentation puts the reasoning plainly: malware is usually detected quickly. A cooldown means your installs never see a version young enough to still be undetected. npm added min-release-age in v11.10.0; the npm config reference defines it in days, with a default of null (off).
With min-release-age=60, a plain npm install esbuild resolved 0.28.1, because 0.28.2 was 49 days old. Asking for the new version explicitly fails:
npm error code ETARGET
npm error notarget No matching version found for esbuild@0.28.2 with a date before 7/28/2026, 1:53:58 PM.
Use min-release-age-exclude for your own scoped packages, and npm install --min-release-age=0 pkg@version when you have read an urgent security release and want it now.
The same idea in pnpm and Yarn
| Tool | Setting | Unit | Default |
|---|---|---|---|
| npm (11.10+) | min-release-age in .npmrc |
days | off |
| pnpm (10.16+) | minimumReleaseAge in pnpm-workspace.yaml |
minutes | 1440 from v11 (0 before) |
| Yarn (4.12+) | npmMinimalAgeGate in .yarnrc.yml |
duration, e.g. 7d |
1d |
The pnpm values are from pnpm's supply-chain security page, which also documents allowBuilds for its own script allowlist. The Yarn values are from Yarn's security documentation, which notes that Yarn stopped running third-party postinstalls by default in 4.14.
Two traps in the cooldown
It only applies when npm resolves a version. npm ci installs exactly what the lockfile says. With a lockfile already pinning esbuild@0.28.2, npm ci --min-release-age=60 installed 0.28.2 without complaint. The cooldown protects the moment a version enters the lockfile, so the lockfile diff in a pull request is still where a bad version gets caught.
An older npm ignores the setting silently. On npm 11.2, npm config get min-release-age printed undefined, and npm install esbuild with the flag set installed 0.28.2 anyway: no warning, no error. Check npm --version on every machine and CI image that installs, including any server you deploy a Node app to, not just your own.
If you publish packages
The worm spread by stealing publish tokens. npm trusted publishing replaces the long-lived token with a short-lived OIDC credential from GitHub Actions, GitLab CI/CD or CircleCI, and needs npm 11.5.1 or later and Node 22.14.0 or later. When a public package is published from a public repository through GitHub Actions or GitLab, npm also generates a provenance attestation automatically. In GitHub Actions the job needs permissions: id-token: write.
The short checklist
- Upgrade to npm 12On npm 10 or 11, set ignore-scripts=true and run needed scripts by hand
- Commit allowScriptsPinned to reviewed versions; review every change to it
- Commit the lockfileInstall with npm ci in CI
- Set min-release-ageConfirm every installer runs npm 11.10 or later
- Publish with trusted publishingA short-lived OIDC credential, not a stored token
Questions this raises
Why did npm install not run a dependency's postinstall script?
npm 12 blocks lifecycle scripts from dependencies unless allowScripts in package.json approves them. The install prints an npm warn install-scripts message and still exits 0. Run npm install-scripts ls to see what was blocked.
How do I allow install scripts for one package in npm 12?
Run npm install-scripts approve <package>, which writes an entry pinned to the installed version into allowScripts, then npm rebuild <package> to run the script once. The next release of that package is blocked again until it is approved.
What does min-release-age do in npm?
It makes npm refuse to resolve any version published fewer than that many days ago, so a malicious release has time to be detected before it reaches you. It was added in npm 11.10.0 and is off by default.
Does npm ci respect min-release-age?
No. npm ci installs exactly what the lockfile pins, whatever its age. The cooldown applies only when npm resolves a new version, so review lockfile changes in pull requests as well.