If you asked a security engineer to design a package manager from scratch with supply chain attacks in mind, they probably wouldn't include a mechanism that executes arbitrary shell scripts from third-party code automatically without prompting at install time. And yet, that's exactly what npm used to do. Every time you ran npm install, npm handed control of your machine to every package in your dependency tree, including packages three or four levels deep that you've never heard of, written by maintainers you've never audited. And they finally killed it this week.
It had become an oh so familiar story, npm packages compromised in a coordinated supply chain attack. Attackers hijack the primary maintainer's account and publish a backdoored release. And when victims downloaded those releases, a malware payload was delivered via a postinstall script. While these scripts have legitimate uses they were primarily used to deliver almost every supply chain attack. Stealing credentials and sending them off of an attacker, before worming its way through your npm packages too. At some point, the question wasn’t "how do we detect malicious postinstall scripts?" It was: "why are we even running them automatically in the first place?" Because, if npm had shut this down after the first Shai-Hulud attacks in October last year, it’s doubtful that we’d have the same volume of worms we’ve been experiencing these last few months.
A private burial will be held in July 2026, when v12 ships. Mourners (and the curious) are welcome to pay early respects now, behind warnings, on npm 11.16.0 or newer.
A life of service to the wrong people
npm (Node Package Manager) is the default package manager for Node.js and the largest software registry in the world, with more than two million packages. It's how JavaScript and TypeScript developers install libraries, running npm install in a project directory downloads all dependencies listed in package.json, including their dependencies, and their dependencies' dependencies (and so on and so on).
What npm used to do, up until June 10th, and what many folks outside the JavaScript ecosystem didn’t realize, is execute code at install time. npm used to support what are called lifecycle scripts, these are hooks defined in a package's package.json that run at specific points in the install process.
preinstall- runs before anything is installedinstall- runs during installationpostinstall- runs after the package and all its dependencies are installedprepare- runs on install and before publishing
These scripts run as shell commands (with the full privileges of whoever ran npm install). So that could be a developer’s machine or a CI/CD pipeline. Both full of juicy secrets like cloud credentials, deployment tokens, crypto wallets, etc. Every package in your dependency tree, including transitive dependencies you've never explicitly chosen, could define these scripts, and npm dutifully ran them automatically.
What its passing prevents (and what it does not)
The deceased did not go alone. npm v12 settles the affairs of two relatives in the same will. For a long time, npm's answer was to keep things as they were. That answer has now changed.
Npm v12 will flip the switch, allowScripts now defaults to off. npm install will no longer run preinstall, install, or postinstall from dependencies unless you have explicitly allowed them in your project. The net is wide enough to catch even the respectable relatives: a package with a binding.gyp and no explicit install script still gets blocked, because npm's implicit node-gyp rebuild counts. prepare scripts from git, file, and link dependencies are blocked the same way.
--allow-git now defaults to none. npm will no longer resolve git dependencies, direct or transitive, unless you allow them. This shuts a code-execution path baroque enough that even --ignore-scripts couldn't close it: a git dependency's .npmrc could override the git executable itself.
And finally --allow-remote now defaults to none. npm will no longer pull dependencies from remote URLs, such as https tarballs, unless allowed. (--allow-file and --allow-directory keep their current defaults, for now.)
The family asks that, rather than sending flowers, you spend twenty minutes preparing for the funeral so it doesn't surprise you in July.
The Legitimate Uses
We should be fair to the dead. A small, genuine category of packages relied on lifecycle scripts for entirely respectable reasons.
Native addons were the most common case. Packages like
bcrypt,sqlite3, orgrpcusenode-gypwhich is a build tool that compiles C++ code for the current platform. You can't ship precompiled binaries for every OS and architecture combination, so the package compiles locally during install.Binary bundlers were another. Puppeteer downloads a full Chromium binary at install time using a postinstall script.
esbuildand@swc/coredownload platform-specific prebuilt executables. These are big downloads (Chromium is hundreds of megabytes) that didn’t belong in the npm registry itself.Setup and configuration covers things like generating configuration files, patching dependencies, or running first-time setup that can't happen at runtime, these were used for libraries like AI, that require additional files or config.
None of that was the problem. The problem was that this conduct was trusted by default, for everyone, with no review step, a single open channel held permanently ajar for the convenience of a minority.
What Attackers Actually Did With This
Let’s look at the Axios attack from March, because axios is used in roughly 80% of cloud and code environments with approximately 83 million weekly downloads. The first compromise hit an endpoint 89 seconds after publication, automated pipelines and developer extensions resolving to the latest version the moment it dropped.
The postinstall script in plain-crypto-js executed a 4,209-byte JavaScript file with two layers of obfuscation, reached out to a command-and-control server, downloaded platform-specific RAT payloads for Windows, macOS, and Linux, then deleted itself and swapped in a clean package.json to avoid forensic detection.
This is the same structural pattern used across the s1ngularity campaign and the Shai-Hulud worm. Inject bundle.js, add a postinstall entry, repackage and publish. The universal signature is a tarball with a new postinstall entry even though up until now it never had one, and yet, by default, npm runs it without question!
The attack didn’t even need to target a package you use directly. It only needs to be somewhere in your transitive dependency tree. As the Axios incident showed, neither malicious version contained a single line of malicious code inside Axios itself. The payload lived entirely in a dependency's postinstall hook, triggered automatically by npm.
Here's what made this situation particularly frustrating from a security standpoint for so long: postinstall scripts were not the norm. Academic research has found that around 2.2% of npm packages actually use install scripts, and while this was back in 2022, it’s unlikely this figure has changed significantly. That means 97.8% of packages follow npm's own recommendation not to use them.
Compare this to how other package managers have responded: pnpm v10, released in January 2025, flipped the default: lifecycle scripts in dependencies are now blocked unless you explicitly allow them. The pnpm approve-builds command lets you review which packages are requesting script execution and add them to an allowlist. The escape hatch for teams that need the old behavior is called --dangerously-allow-all-builds, which at least lets you know exactly what kind of decision you’re making.
Bun also blocks scripts by default. Yarn v2+ in Plug'n'Play mode does the same. The npm GitHub repository had open issues requesting this change for years, the most recent, filed in late 2025, states plainly: "There is no justifiable reason for running arbitrary shell scripts by default."
Survived by
The hooks themselves live on, in retirement, as opt-in next of kin in your allowlist. The wider supply chain threat model also survives in robust health: typosquatting, dependency confusion, and plain old malicious runtime code send their regards and have no intention of attending the service.
The deceased's longest-running quote, repeated at every incident for over a decade, was simply: it kept working because we kept running it.
We have, at last, stopped running it.
Rest in peace. You will not be missed, but you will, regrettably, be remembered.