npm v12 is shipping in July 2026, and it is the most significant change to npm in a very long time. It flips npm’s install defaults from “run everything, fetch from anywhere” to “deny by default, opt in explicitly,” and it adds a set of capabilities that, until now, were the reason teams reached for pnpm or Yarn instead.
I’m writing this because I got to contribute to a major part of it. Three of the new features started as RFCs I proposed and then implemented, and over the last several months, I worked on fixing bugs in npm’s isolated install mode (install-strategy=linked) and supporting the new supply-chain security defaults with follow-up PRs as issues surfaced. It was very much a team effort with the npm maintainers. This post is my walk-through of what changed and why it mattered enough to spend months on.
Why I cared in the first place
If you’ve read my previous post on install-strategy=linked, you know the backstory. Gutenberg is a massive monorepo — around 200 workspace packages — and for years there’s been an appetite to move to pnpm for stricter dependency isolation and faster, more correct installs. But migrating the package manager of a project that size, with that many contributors and that much tooling built around npm, is a genuinely hard sell. The community was understandably reluctant.
So I took the other road. At Automattic (where I work), we’re encouraged to contribute to open source — so I rolled up my sleeves and decided to bring the missing capabilities into npm itself, instead of asking everyone to switch package managers. If the thing people wanted pnpm for could be done with npm, the migration question mostly goes away. That meant maturing npm’s isolated mode, and then — once v12’s direction became clear — building the dependency-management features that npm was still missing compared to its peers.
You can see the real-world driver in the Gutenberg PR exploring the linked strategy (WordPress/gutenberg#75814). It even shipped a hand-rolled apply-patches.mjs script to work around the fact that patch-package can’t find dependencies under npm’s .store/ layout. That workaround is exactly the kind of papercut that motivated native patching in npm. The features below didn’t come from a whiteboard — they came from hitting walls in a real, huge monorepo.
The headline: npm v12 is secure by default
The biggest change in v12 isn’t a command you run. It’s a default that flipped. Historically, npm install would happily run arbitrary lifecycle scripts from any dependency, resolve Git dependencies, and download remote tarballs. That’s a lot of implicit trust, and the npm ecosystem has paid for it repeatedly.
In September 2025, a phishing attack hijacked the maintainer of chalk, debug, and around 16 other packages — together more than 2 billion weekly downloads — and pushed versions carrying a crypto-clipper that rewrote wallet addresses in the browser (Aikido’s writeup). Days later, the self-replicating Shai-Hulud worm tore through 500+ packages by abusing postinstall scripts to steal credentials and automatically republish itself to every package the stolen tokens could reach — the first true worm-driven npm compromise, serious enough to draw a CISA alert. It returned in an even larger second wave that November.
And it didn’t stop in 2025. In May 2026, a coordinated campaign chained a GitHub Actions “pull_request_target” exploit and OIDC-token theft to publish malicious versions of 42 @tanstack/* packages — @tanstack/react-router alone pulls ~12.7M weekly downloads — carrying a “Mini Shai-Hulud” credential-stealing payload, as part of a wave that hit 170+ packages across npm and PyPI (TanStack’s postmortem, Snyk). The publish vector there was CI, not install scripts — but the payload, like the others, runs when you install it. The single most effective mitigation against payloads that execute on install is exactly the default v12 now ships: don’t run dependency install scripts unless you’ve said so.
v12 changes the defaults:
- Install scripts are denied by default.
preinstall,install, andpostinstallfrom your dependencies no longer run unless you explicitly allow them via anallowScriptspolicy in yourpackage.json(or approve them interactively). - Git dependencies are blocked unless you opt in with
--allow-git. - Remote tarball URLs are blocked unless you opt in with
--allow-remote.
This is npm catching up to where pnpm already went — its v10 made install scripts opt-in — and to the direction the wider ecosystem has been moving: make the dangerous thing opt-in, not opt-out. It’s the right call, and it’s overdue.
I didn’t design that policy, but I chipped in once it landed. Flipping a permissive default to a strict one surfaces a long tail of edge cases, especially in isolated mode, so I fixed a handful: getting the allow-remote/allow-git exemptions right under the linked strategy, and keeping isolated packages’ install scripts able to find their native bindings.
All of that is in service of one thing: secure-by-default has to hold under both layouts, hoisted and isolated. If isolated mode is going to be a real choice, it can’t be a second-class citizen of the security model.
The three features I brought to npm
These are the ones that started as my RFCs and that I implemented. Each one closes a gap between npm and its peers — and two of the three came directly out of isolated mode itself, where stricter resolution turns a sloppy upstream manifest from a silent non-issue into a hard error you have to deal with.
1. Native dependency patching — npm patch
Sometimes a dependency has a bug, and you can’t wait for an upstream release. Every other major package manager has a first-class answer: pnpm patch, yarn patch. npm’s answer was “use a third-party tool like patch-package,” which breaks down the moment you adopt isolated mode (see that Gutenberg workaround script above).
v12 ships native patching (#9439):
npm patch lodash # opens an editable copy # ...make your edits... npm patch commit /tmp/... # writes a .patch file + records it in package.json npm patch ls npm patch update lodash --to 4.17.22 npm patch rm lodash
Your edit is recorded in patchedDependencies and re-applied automatically on every install, so a one-off fix becomes reproducible for the whole team and survives reinstalls — no third-party tooling, no postinstall hacks. It works the same whether you’re on the classic layout or the isolated one, and when upstream finally ships the real fix you just npm patch rm it and the dependency goes back to its published version.
This is straight parity with pnpm and Yarn.
2. packageExtensions — declarative manifest repairs
Plenty of published packages have wrong or missing metadata: a dependency they rely on but never declared, a peer dependency they forgot to list, a bad range. Under the classic flat node_modules you often never notice — the missing package gets hoisted into a shared node_modules, and Node’s module resolution algorithm, which walks up the directory tree checking each node_modules along the way, finds it anyway (the infamous “phantom dependency”). Isolated mode takes that crutch away: a package can only see what it actually declares, so a dependency that used to resolve by accident now fails outright. That’s not isolated mode misbehaving; it’s isolated mode being honest. But it does mean you need a way to repair someone else’s manifest without forking it. pnpm and Yarn both have packageExtensions for exactly this; npm didn’t.
v12 adds root-owned packageExtensions (#9496):
{
"packageExtensions": {
"some-pkg@^1": {
"peerDependencies": { "react": "*" },
"dependencies": { "missing-dep": "^2" }
}
}
}
These are applied before npm resolves the tree, so the missing dependency actually gets installed and resolves at runtime, and the rule is recorded in your lockfile so everyone on the team gets the same result. It validates what you write — overlapping selectors, orphaned peer-meta, and duplicate-dep conflicts are rejected rather than silently ignored — and it stays consistent across npm ls, npm query, and the rest, including under isolated mode.
Again: parity with pnpm and Yarn, for a problem every large dependency tree eventually hits.
3. .npm-extension — imperative manifest repairs
packageExtensions is declarative, which is great until your fix needs a condition. “Add this dependency only for versions 1.x.” “Strip this broken optional dependency on Windows.” Declarative rules can’t express that.
So I added an imperative counterpart (#9586): a root-owned .npm-extension.mjs (or .cjs) that exports a transformManifest(pkg, context) function npm calls for each dependency manifest before resolution.
// .npm-extension.mjs
export function transformManifest (pkg, context) {
if (pkg.name === 'some-pkg' && pkg.version.startsWith('1.')) {
pkg.dependencies = { ...pkg.dependencies, 'missing-dep': '^2' }
context.log('patched some-pkg@1 manifest')
}
return pkg
}
It runs before packageExtensions, so the two compose, and editing the file is picked up on your next install. It’s also careful about trust: npm ci validates the file but does not execute it, and --ignore-scripts disables it. If you’ve used pnpm, this is the analog of its .pnpmfile.cjs readPackage hook — the escape hatch for the manifest problems that no declarative rule can express.
Of the three, it’s the one I wanted most.
And the foundation: isolated mode finally grew up
None of the above matters if the install layout underneath it is shaky. install-strategy=linked — npm’s isolated, pnpm-style layout where every package lives once in a content-addressed node_modules/.store/ and is symlinked into the trees that need it — had been experimental and, frankly, buggy.
Since February — found by keeping that Gutenberg PR rebased almost daily and chasing down whatever broke — I’ve landed more than 60 PRs hardening it. A non-exhaustive sense of the surface area: scoped packages, aliases and peer deps (#8996); relative file: dependencies (#9030); idempotent re-installs (#9031, #9094); --omit handling (#9066); workspace hoisting and visibility (#9076, #9657); npm link (#9167); overrides through symlinks (#9198, #9658); store cleanup and dangling symlinks (#9309, #9647); correct dev/prod flags, query locations, and npm ls/audit/sbom under the store layout (#9655, #9656, #9625); and repairing a wrong-but-existing symlink target that an interrupted update could leave behind (#9628).
In my testing, isolated mode is finally solid enough to rely on for real work. The rough edges that made it a non-starter — wrong npm ls/query output, npm exec falling back to the registry, stale and dangling symlinks, audits that quietly missed real vulnerabilities — are gone, and those fixes are in v11, the current release, not just the upcoming v12. On v12 the same holds for the new surface: patch, packageExtensions, the manifest extension, and the secure defaults. And it’s now official: isolated mode is no longer flagged experimental — a supported, opt-in install strategy in v12, and backported to v11 (landing in v11.18). It isn’t perfect, but turning it on no longer feels like a gamble.
On par with its peers
To be clear, almost none of these ideas are npm’s. pnpm and Yarn pioneered isolated installs, dependency patching, manifest repairs, and stricter install defaults years ago, and they’re excellent, well-engineered tools — a lot of this work was learning from what they got right. What v12 really does is bring npm on par with them: the capabilities people used to switch package managers for are now available in npm itself. None of that is a knock on pnpm or Yarn. It just means more good options, and a lot less friction for teams that aren’t going to migrate.
What this means for big monorepos (and Gutenberg)
This was always the goal. A project like Gutenberg can adopt isolated mode for stricter, pnpm-like dependency isolation, patch a misbehaving dependency with npm patch instead of a brittle external script, repair broken third-party manifests with packageExtensions or .npm-extension, and inherit the new supply-chain defaults, all without switching package managers or retraining hundreds of contributors. The hesitation always made sense. npm just had to catch up, and with v12 it mostly has.
A thank-you
None of this happens in a vacuum. A big shoutout to Michael Smith (@owlstronaut), who has been genuinely supportive and encouraging throughout this work. Landing this much change in npm — new defaults, three new features, and a long tail of isolated-mode fixes — takes maintainers who are willing to engage, review, and cheer the work on, and Michael has been exactly that. Thank you.
Thanks also to Automattic for fostering a culture where contributing to open source is not just allowed but actively encouraged. This work wouldn’t have happened without that support.
Try it today
You don’t have to wait for July. The supply-chain defaults are already available behind warnings in npm 11.17.0 and newer, so you can turn the strict behavior on today and see what trips. Isolated mode is in the current v11 too — with every fix above — so flip install-strategy=linked in a branch and run your test suite. (The three new features — npm patch, packageExtensions, and .npm-extension — arrive with v12 itself.) Isolated mode is no longer experimental — a supported, opt-in strategy as of v12, with the change backported to v11 (v11.18). It’s dramatically more solid than it was, and more people kicking the tires is how it keeps improving.
And one specific ask if you publish packages: install and test your own library under isolated mode. It surfaces undeclared dependencies and missing peers at your build time — the exact bugs the flat node_modules quietly hides from you and then hands to everyone who installs you. The flat layout resolving a dependency you forgot to declare isn’t doing you a favor; it’s a bill you’re passing downstream.
Isolated mode catches a lot of this, but not all of it. The subtler cases are dependencies in the wrong place: a devDependency whose types leak into your emitted .d.ts through an import('react'), or a runtime dependency declared only at your monorepo’s root that Node’s resolution algorithm happily finds by walking up to the root node_modules. Neither breaks for you — your .d.ts is never executed at install, and the monorepo root is always there while you develop — yet a consumer who installs just your package, with no monorepo around it, hits an unresolved import. It’s a trap for anyone publishing from a monorepo, and I kept hitting it across 100+ @wordpress/* packages — so I built a small, general-purpose tool for it: @mawesome/dependency-audit verifies that every bare import in your published artifact — runtime JS and emitted types alike — is declared and resolvable against your declared dependencies, not your ambient node_modules. Point it at any package on npm — npx @mawesome/dependency-audit @sindresorhus/is@latest — or run it on your own build before you ship, and your users won’t have to reach for packageExtensions to clean up after you. You can even try it live in your browser on any published package — nothing to install.
And isolated mode most of all: try it, break it, report it — on npm/cli. Just about every one of those 60+ fixes started as something that broke; that’s how it got this far, and reports are how it keeps getting better.