npm vs pnpm 11: install script security and allowBuilds
Four npm and pnpm install behaviours, measured version by version, and the configuration that makes them match.
- 18 min read
Installing a dependency runs code before the project imports it. That code sits in install scripts, commands a package declares in its package.json for the package manager to run when it installs the package. Nobody has reviewed the package yet, and the script runs with the permissions of the account that started the install.
Both package managers used to run those scripts without asking for confirmation, and both have stopped. The first was pnpm 10, released on 7 January 2025, which announced it as a breaking change made to increase security1. Version 11 turned the warning into an error and added a second defence. In July 2026, npm 12 did the same. It blocks dependency install scripts unless the package is named in a new field, allowScripts2.
So comparing npm and pnpm means comparing specific versions. Of the four versions measured, two block the scripts with a warning and without stopping the install. One blocks them with an error, and the fourth runs them.
The table compares npm 11.8.0 and npm 12.0.2 with pnpm 10.0.0 and pnpm 11.26.0 across four behaviours, with nothing configured. Section 7 collects the configuration that gives the same result on an npm version before 12. Every cell comes from running the command, except the 1440 in the last row, which is the value pnpm’s documentation declares. Each sample project fits in one or two files, written out in full in its section.
| Behaviour | npm 11.8.0 | npm 12.0.2 | pnpm 10.0.0 | pnpm 11.26.0 |
|---|---|---|---|---|
A dependency’s postinstall |
runs | blocked, warning, exit 0 | blocked, warning, exit 0 | blocked, exit 1 |
require of an undeclared package |
resolves | resolves | MODULE_NOT_FOUND |
MODULE_NOT_FOUND |
| CI install with a stale lockfile | installs another version, exit 0 | installs another version, exit 0 | exit 1 | exit 1 |
| Wait before resolving a freshly published version | setting does not exist | setting present, unset | none | 1440 minutes |
The table measures defaults and not capabilities. The default in the first row changes with the manager’s major version. So the first thing to know is which version the machine runs. Row 1 can be matched in npm 12 with strict-allow-scripts=true, which makes the install exit 1 (section 1). Rows 3 and 4 can be matched with npm ci in continuous integration and a setting in .npmrc (section 7). Row 2 cannot be matched, because it depends on how each manager lays packages out on disk.
1. What third-party code the install runs
A package can declare a script that runs when it is installed. A two-file project is enough to see it. The dependency points with file: to a local folder, so the install downloads nothing from the network.
// package.json
{
"name": "install-test",
"version": "1.0.0",
"private": true,
"dependencies": { "evil-postinstall": "file:./evil-postinstall" }
}
// evil-postinstall/package.json
{
"name": "evil-postinstall",
"version": "1.0.0",
"scripts": { "postinstall": "node -e \"require('fs').writeFileSync('IT-RAN.txt','')\"" }
}
The name evil-postinstall was invented for the article and does not exist in the public npm registry. This was checked on 7 September 2026 with npm view evil-postinstall, which returns E404.
With npm 11.8.0, the install finishes cleanly and the file appears, with nothing in the output to mention it:
npm install
# exit 0
ls evil-postinstall/IT-RAN.txt
With npm 12.0.2, the same command on the same project does not create the file. It shows a warning and exits with code 0, which means the command completed without error:
npm install
# npm warn install-scripts 1 package had install scripts blocked because they are
# npm warn install-scripts not covered by allowScripts:
# npm warn install-scripts evil-postinstall@1.0.0 (postinstall: node -e "...")
# npm warn install-scripts Run `npm install-scripts ls` to review, or
# npm warn install-scripts `npm install-scripts approve <pkg>` to allow.
# exit 0
An exit code of 0 does not stop continuous integration, the server that runs the checks on every change. So this block protects the machine but obliges nobody to review the list. The two commands in the warning show the list and approve a package, and the permission is stored in allowScripts, inside package.json2.
To make npm 12 stop the install, turn on strict-allow-scripts. With npm 12.1.0, the same command ends like this:
npm install --strict-allow-scripts=true
# npm error code ESTRICTALLOWSCRIPTS
# npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts:
# npm error evil-postinstall@1.0.0 (postinstall: node -e "...")
# exit 1
Write strict-allow-scripts=true in the project’s .npmrc so that it applies on every machine.
With pnpm 11, the install stops earlier:
pnpm install
# [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: evil-postinstall@file:evil-postinstall
# Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
# exit 1
Of the four versions measured, pnpm 11 is the only one that exits with a code other than 0 when it meets an install script, without anyone configuring anything.
Writing an empty file is harmless. With the same permission, a script can read environment variables, search the disk for credentials or publish packages with the credentials of whoever is installing.
That last case happened in September 2025. The Shai-Hulud worm spread through npm by way of a postinstall script, placed in packages from compromised maintainers3. CISA went as far as issuing an alert4.
Blocking by default also protects against a malicious package that nobody has detected yet, because it does not need to recognise it. It limits what any package can do while it installs.
On an npm version before 12, get the same block with one line in .npmrc, the project configuration file. The ignore-scripts setting has existed for years and defaults to false6. It is still false in npm 12: the allowScripts block is a new and parallel mechanism, and it does not change that setting’s value.
# .npmrc
ignore-scripts=true
With that line, the install of the project above finishes without creating IT-RAN.txt.
The setting also affects the project’s own scripts. The npm documentation says that npm run, npm test and npm start still run the script you ask for, but not its pre and post scripts6. In the measurement, with npm 11.8.0, ignore-scripts=true did not run the project’s prepare during npm install either. If the project uses prepare to install git hooks, as husky does, those hooks stop being installed. Run that step separately, with npm run prepare.
Blocking scripts has a cost. Packages such as esbuild, puppeteer or sqlite3 download or compile their binary during the install, and without it they do not work. Run the script of each one separately, without the block and for that command only:
npm rebuild esbuild --ignore-scripts=false
The --ignore-scripts=false flag is necessary, because with ignore-scripts=true in the configuration, an npm rebuild without it runs nothing either.
2. How to fix ERR_PNPM_IGNORED_BUILDS in pnpm 11 with allowBuilds
pnpm 10 stopped running dependency scripts during the install1. It shows a warning and exits with code 0, so, like npm 12 (section 1), it does not stop continuous integration:
The following dependencies have build scripts that were ignored: esbuild
In that version, the permission is granted in package.json, with the pnpm.onlyBuiltDependencies field.
pnpm 11.0.0, from 28 April 2026, changed both things7. The warning became the error ERR_PNPM_IGNORED_BUILDS, with exit code 1, and the install fails until somebody decides which scripts are allowed. The permission also moved to a different file. onlyBuiltDependencies, along with onlyBuiltDependenciesFile, neverBuiltDependencies, ignoredBuiltDependencies and ignoreDepScripts, was removed, and allowBuilds in pnpm-workspace.yaml replaces it8. Version 10.26.0 had already added allowBuilds as an alternative to those settings8, so you can migrate before upgrading to 11.
# pnpm-workspace.yaml
allowBuilds:
esbuild: true
With that entry, the install ends in 0 again and the esbuild script runs.
When pnpm 11 blocks a script, it writes an entry with a pending value to pnpm-workspace.yaml itself:
allowBuilds:
esbuild: set this to true or false
It is unusual for an install to modify a versioned file. In exchange, the decision lands in the repository history instead of staying on the machine of whoever installed. The pnpm approve-builds command walks the list interactively and writes the answers to that same file8.
3. How to find dependencies the code uses without declaring them
Unlike the scripts, this risk needs no malicious code and produces no error. A project that only declares debug also receives ms, because debug needs it to work. ms is a transitive dependency, a dependency of a dependency. The question is whether the project’s own code can import it.
{
"name": "phantom",
"version": "1.0.0",
"private": true,
"dependencies": { "debug": "4.3.4" }
}
node -e "require('ms')"
With npm, the line works on both versions in the table. With pnpm it fails with MODULE_NOT_FOUND on both of its versions, because pnpm only puts the dependencies the project declares at the root of node_modules.
If a require works without the package being declared, another package chooses the version of that package. Nobody pinned it and nobody reviews it on an update. It also disappears the day debug changes or replaces its own dependency.
How to check it. Install the project with pnpm and start the application. Every MODULE_NOT_FOUND that appears is a package that was missing from the manifest.
4. How to make the install respect the lockfile
The lockfile (package-lock.json in npm, pnpm-lock.yaml in pnpm) pins the exact version of every dependency. It stops helping the moment the command that runs on the server is allowed to rewrite it.
To reproduce it, install a project with debug at 4.3.4. Then change package.json to 4.4.0 without touching the lockfile, which is what a badly merged branch leaves behind. The CI=true variable simulates the continuous integration environment:
CI=true npm install
# exit 0
node -p "require('./node_modules/debug/package.json').version"
# 4.4.0
npm installs a version the lockfile does not name, updates the file so that it matches and exits 0. Nothing in the output says that the version changed. With pnpm, the same case ends like this:
CI=true pnpm install
# ERR_PNPM_OUTDATED_LOCKFILE
# exit 1
pnpm stops and leaves 4.3.4 in place. The --frozen-lockfile option, which prevents changes to the lockfile, defaults to true when pnpm detects a continuous integration environment, and to false outside one9.
In npm, the equivalent is a separate command that has to be typed: npm ci. It fails if the lockfile does not match package.json, deletes node_modules before starting and never writes to either file10.
npm ci
# npm error code EUSAGE
# exit 1
Both package managers respect the lockfile when asked, and the only difference is what they do when nobody types the explicit command.
5. How to wait before installing a freshly published version
pnpm 11 also changed a second default, which npm 12 does not match. With it, pnpm does not choose a freshly published version until it is at least a day old. The setting is called minimumReleaseAge, it is measured in minutes and it went from 0 to 144011.
# pnpm-workspace.yaml
minimumReleaseAge: 1440
The wait reflects how these attacks happen. The malicious versions of chalk and debug were published in two bursts minutes apart, and were detected the same day5. A one-day wait covers that window, in which the compromised package is already published and nobody has detected it yet.
Asking for an exact version does not skip the wait. With debug pinned to 4.4.3 and a high minimumReleaseAge, the install fails:
Error: ERR_PNPM_NO_MATURE_MATCHING_VERSION
× installing dependencies
╰─▶ 2 versions do not meet the minimumReleaseAge constraint:
debug@4.4.3 was published at 2025-09-13T17:25:19.732Z, within the
minimumReleaseAge cutoff
help: Run the install interactively to approve these picks, add them to
minimumReleaseAgeExclude in pnpm-workspace.yaml, or wait for the
packages to mature.
# exit 1
Add exceptions by hand in minimumReleaseAgeExclude, as the message says. Unlike what it does with allowBuilds, pnpm does not write these entries to the file. Another setting, minimumReleaseAgeStrict, decides whether pnpm fails or chooses an older version that meets the constraint11.
npm has had an equivalent setting, min-release-age, since version 11.10.0, with two differences. It is measured in days rather than minutes, and it comes with no value, so by default it waits for nothing. The min-release-age-exclude setting accepts package names and minimatch patterns, which allow wildcards6.
npm config get min-release-age
# npm 11.8.0 -> undefined (the setting does not exist)
# npm 11.10.0 -> null (it exists, unset)
# npm 12.0.2 -> null
npm also has the before setting, which pins every resolution to a fixed date and also comes with no value.
6. What both package managers do the same way
Both record the integrity hash of every package in the lockfile. If the downloaded content does not match, both stop. If you tamper with a lockfile entry by hand, npm fails with EINTEGRITY and pnpm with ERR_PNPM_TARBALL_INTEGRITY. Both have a command that forces that file to be respected (section 4). Neither inspects what the package code does once installed, which is the chalk and debug case (section 1).
In their current versions, both block install scripts in the same way. They use an allowlist of packages, versioned with the repository, and an interactive command that fills it. In npm, the list is allowScripts in package.json and the command is npm install-scripts approve. In pnpm, the list is allowBuilds in pnpm-workspace.yaml and the command is pnpm approve-builds. The default severity differs: npm 12 warns and exits 0, and pnpm 11 exits 1. With strict-allow-scripts=true, npm 12 also exits 1.
The npm registry is also changing how packages are published. In September 2025, npm announced publishing with a verified identity, called trusted publishing, and the retirement of classic tokens. It also announced a seven-day limit on the lifetime of granular tokens and a shorter expiration for those that can publish12. These are changes to how a package is published, not to what happens when it is installed, so they add to the above.
7. How to configure npm safely
You do not need to switch package manager. What you can match depends on the npm version:
| Version | Install scripts | Minimum release age |
|---|---|---|
| npm 12 | allowScripts by default; strict-allow-scripts=true to exit 1 |
min-release-age |
| npm 11.16 to 11.x | allowScripts and strict-allow-scripts=true; unconfigured, it warns and runs the scripts |
min-release-age |
| npm 11.10 to 11.15 | ignore-scripts=true |
min-release-age |
| npm before 11.10 | ignore-scripts=true |
does not exist |
Check the version first with npm --version. On npm 12, keep the allowScripts block and add the line that stops the install:
# .npmrc, npm 12
strict-allow-scripts=true
npm 11.16 added allowScripts and strict-allow-scripts, and npm 11.18 the npm install-scripts command. In those versions, without strict-allow-scripts=true the install warns but runs the scripts, so add the same line. On a version before 11.16, no setting gives an allowlist of packages. Write ignore-scripts=true, the setting from section 1, and commit it to the repository so that it applies to the whole team. Bear in mind that it also turns off the project’s prepare (section 1):
# .npmrc, npm before 11.16
ignore-scripts=true
On the server, install with npm ci. The command respects the lockfile (section 4) and also the .npmrc, so it applies both protections:
npm ci
With ignore-scripts=true, the project from section 1 installs without creating IT-RAN.txt and exits 0. It is the result pnpm 10 and an unconfigured npm 12 give, reached with a different setting.
How to check it. On the machine that installs, check that the setting is active:
npm config get strict-allow-scripts # npm 12, or 11.16 and later
npm config get ignore-scripts # npm before 11.16
# true
Check the registry signatures too. On a project with real dependencies, npm audit signatures confirms that what was downloaded is signed by the npm registry:
npm audit signatures
# 2 packages have verified registry signatures
For the minimum release age, add min-release-age to .npmrc. It requires npm 11.10.0 or later, and the value is measured in days:
# .npmrc, npm 11.10.0 or later
min-release-age=1
One difference remains that no npm setting covers. Transitive dependencies can be imported without being declared (section 3), because npm places every one it can at the root of node_modules. A dependency analyser catches that, not a line of configuration.
8. What switching from npm to pnpm costs
Moving to pnpm has four consequences from the first day:
- Code that imported packages without declaring them stops starting, with the
MODULE_NOT_FOUNDfrom section 3. Declare each package, and do not disable pnpm’s isolation to hide the error. - The first install stops with the list of blocked packages, and somebody has to review it. That review is the useful part of the block, because it forces somebody to read why a package needs to run code while it installs.
pnpm-workspace.yamlbecomes part of the repository, and pnpm modifies it during installs (section 2). If an entry appears without anyone having added a dependency, check where it came from.- The minimum release age delays any freshly published update by a day, including an urgent fix. To install it sooner, add it to
minimumReleaseAgeExclude(section 5).
How to check which protection a project has
The protection depends on the version the machine runs and on its configuration:
- pnpm 9 and npm 11 without configuration block no scripts.
- pnpm 10 and npm 12 show a warning and carry on.
- pnpm 11, and npm 12 with
strict-allow-scripts=true, stop the install with an error. - npm 11 before 11.16 with
ignore-scripts=trueblocks every script, including those of packages that do need them, because it does not accept an allowlist. An unconfigured npm 12 blocks only the ones not inallowScripts.
How to check it. Check which version the machine runs:
npm --version
pnpm --version
Declaring the version in package.json documents the intent, but does not enforce it:
{ "packageManager": "pnpm@10.11.0" }
In a project with that field and without Corepack enabled, pnpm --version still returns the version installed on the machine. That version may be older than any in the table.
Corepack is the utility that uses the packageManager field to install that version of the package manager. Node.js ships it from version 14.19.0 up to 24; from Node.js 25 you install it with npm install -g corepack13. For the declared version to be the one that runs, enable Corepack with corepack enable or install the package manager as a dependency of the project itself. Until then, the field is a declaration and not a control.
Dependencies are one of the ten layers in How to secure a web application, which links the article for each layer.
References
- pnpm. Release pnpm 107 January 2025.github.com↩
- npm. npm-install-scriptsnpm 12.0.0, 8 July 2026; allowScripts and the default block.docs.npmjs.com↩
- Unit 42, Palo Alto Networks. Shai-Hulud Worm Compromises npm Ecosystem in Supply Chain Attackunit42.paloaltonetworks.com↩
- CISA. Widespread Supply Chain Compromise Impacting npm Ecosystem23 September 2025.cisa.gov↩
- Socket. npm Author Qix Compromised in Major Supply Chain Attacksocket.dev↩
- npm. config, ignore-scripts and min-release-agev12 documentation.docs.npmjs.com↩
- pnpm. pnpm 11.028 April 2026.pnpm.io↩
- pnpm. Settings, Build Settingspnpm.io↩
- pnpm. pnpm install, --frozen-lockfilepnpm.io↩
- npm. npm-cidocs.npmjs.com↩
- pnpm. Settings, minimumReleaseAgepnpm.io↩
- GitHub. Our plan for a more secure npm supply chain22 September 2025.github.blog↩
- Node.js. Corepack, READMEshipped with Node.js from 14.19.0 up to, but not including, 25.0.0.github.com↩