tl;dr npm 12 makes dependency lifecycle scripts, Git dependencies, and remote tarballs opt-in. Run
npm approve-scripts --allow-scripts-pending, approve what you trust, commit the allowlist. However, npm 12 does not make your dependencies trustworthy. It just stops them from running before you have decided whether they are.
The attacks all look the same. Someone steals a publish token, pushes a patch
release with a postinstall script, and every CI runner that installs within
the hour executes it with whatever credentials that runner holds. The contents
of the package barely matter. The execution is the payload.
npm 12, released on 8 July 2026, ends that as a default. I consider it the most important thing to happen to the npm CLI in years.
What is opt-in now
-
allowScriptsdefaults to off.preinstall,install, andpostinstallfrom dependencies no longer run. Neither do implicitnode-gypbuilds — a package with abinding.gypand no declared install script is blocked too.preparescripts from git, file, and link dependencies go the same way. -
--allow-gitdefaults tonone, direct or transitive. The reason should make you sit up: a Git dependency's.npmrccould override the Git executable, and that worked even with--ignore-scripts. The flag we all reached for was never the boundary we thought it was. -
--allow-remotedefaults tonone. No more https tarballs pulled in as transitive dependencies.--allow-fileand--allow-directoryare unchanged.
The allowlist is the actual security control
npm approve-scripts --allow-scripts-pending
npm approve-scripts
npm deny-scripts
The allowlist is written into your package.json, and you commit it. That is
the part that matters: "which of our dependencies may execute code on our build
agents" stops being an assumption and becomes a reviewable diff. The next time a
compromised release adds a postinstall, someone has to approve it in a pull
request first.
The first npm ci after the upgrade will fail, loudly, and native dependencies
will break first. Make sure to read the error before running it again with
--allow-scripts.
Two smaller doors, also closed
-
npm shrinkwrapis gone.npm-shrinkwrap.jsonis no longer honored — including from inside dependency tarballs, where a dependency could previously dictate part of your resolved tree. npm 11.17.0 also shipped a fix to reject path traversal entries when inflating dependency shrinkwraps, which tells you what kind of neighbourhood this feature lived in. -
Unknown configs now throw. Until npm 12,
ignore-scipts=truein a CI.npmrcdid nothing, silently, forever — a security control that only existed in your head. Now it fails the build. Misconfigured guardrails should be loud.
Move to trusted publishing now
Since a stolen token is how this starts, npm is deprecating 2FA-bypass granular access tokens in the same breath. From early August 2026 they can no longer change account, package, or organization settings; around January 2027 they lose direct publish entirely. I have been migrating CI publishing to trusted publishing for a few months now—it is a quick change.
None of this applies if you are still running npm 11
Everything above is a property of the npm binary that happens to be running, not
of your repository. engines in package.json is a recommendation: npm
prints a warning and installs anyway. So pin the version, and make the install
fail when the pin is not met. This is what I
did in this project:
{
"engines": {
"node": ">=24.18.1 <25",
"npm": ">=12.0.2 <13"
},
"devDependencies": {
"check-node-version": "4.2.1"
},
"scripts": {
"prepare": "husky && case \"$npm_command\" in install|ci) check-node-version --package ;; esac"
}
}
check-node-version with
--package reads the ranges straight out of engines, so the required version
lives in exactly one place. Hooking it into prepare runs it on npm install
and npm ci — which is precisely the moment someone would otherwise get an
install that quietly ignores their allowlist.
The case around it is a necessary workaround for my projects: prepare also
runs on npm publish and on npm pack, and
semantic-release
publishes with its own bundled npm — @semantic-release/npm depends on
npm@^11.6.2 — rather than with the one installed on the runner. Without the
guard, the hook will prevent semantic release to invoke the bundled npm.
Then the runner has to actually have npm 12.
actions/setup-node gives you whichever
npm ships with that Node.js release, which is not necessarily the one you asked
for. I install it explicitly, again from engines, so there is still a single
source of truth:
# .github/actions/install-npm/action.yml
name: Install NPM
description: >
Installs the NPM version required by the `engines.npm` directive in
package.json, which is required for the project to build correctly.
runs:
using: composite
steps:
- name: Install NPM
shell: bash
run: |
npmVersion="$(node -p 'require(`${process.env.GITHUB_WORKSPACE}/package.json`).engines.npm')"
echo "Installing npm@${npmVersion}"
npm install -g "npm@${npmVersion}"
npm install -g "npm@>=12.0.2 <13" is a valid range install, so the range can
be passed through unchanged. Give setup-node the same range for Node.js
(node-version: ">=24.18.1 <25"), drop the composite action in right after it,
and every workflow is on the version the repository asks for:
- uses: actions/setup-[email protected]
with:
node-version: ">=24.18.1 <25"
cache: "npm"
- name: Install NPM version specified in package.json
uses: ./.github/actions/install-npm
- name: Install dependencies
run: npm ci --no-audit
Using JSR.io breaks NPM v12 security improvements
Unfortunately, because of NPM v12 I have to move packages off of
JSR. In some setups I have transitive dependencies to more
JSR hosted packages and this requires to set allow-remote=all for NPM v12.
JSR packages are consumed through JSR's NPM compatibility layer, as
npm:@jsr/<scope>__… aliases that resolve to tarball URLs on npm.jsr.io.
NPM v12 classifies those as remote fetches and refuses them unless allow-remote
is set. allow-remote=root only covers a project's own top-level dependencies.
As soon as one of them pulls in a transitive JSR dependency, every host project
consuming it has to relax the setting to allow-remote=all, which opens up the
attack surface of arbitrary code sources that NPM v12 just closed.
Publishing to NPM removes the need for it entirely: these packages become ordinary registry dependencies.
Tighten your AWS credential usage
Even with NPM v12 it is still possible for malware to be executed. If an attack
manages to infect a library that you execute manually (e.g. jest) it can also
run arbitrary code in the context of your shell.
To make it harder for credential stealers like in the
recent NPM supply chain attack
to acquire working credentials, stop using AWS access keys in your .env files.
Switch to
AWS IAM Identity Center
which is free and allows to get short-lived credentials in a very convenient way
using aws sso.