Why Installing an npm Package Can Execute Code on Your Machine (And Why npm v12 Finally Changes That)
🎙️ This article is based on Episode 2 of Café con CVEs, our Spanish-language podcast where we break down Node.js security, the software supply chain, and real-world vulnerabilities for developers.
If you'd rather watch or listen, you can find the full episode on YouTube:
📺 Watch Episode 2: https://youtu.be/mbd34JCToxU
For years, the Node.js community has typed the same command billions of times:
npm install
It's probably the most frequently executed command in the JavaScript ecosystem. We use it to start new projects, add dependencies, update existing ones, or simply bootstrap a development environment. It has become such a routine part of our workflow that most of us no longer stop to think about what actually happens during those few seconds while npm fills node_modules.
Most developers would probably describe the installation process in simple terms: npm downloads a package from the registry and copies its files onto the local machine. That assumption feels reasonable—but it has never been entirely true.
Since its earliest versions, npm has allowed packages to execute arbitrary code during the installation process itself, before the developer has imported the package, inspected its source code, or even finished installing the rest of the dependency tree. Originally, this behavior existed for perfectly legitimate reasons. Native modules often need to compile platform-specific binaries, download prebuilt artifacts, or prepare the environment before they can be used. Without installation hooks, many of the packages the ecosystem relies on simply wouldn't work.
The problem wasn't the feature itself.
The problem was the trust model behind it.
For more than a decade, every time developers ran npm install, they implicitly trusted every package in their dependency tree to execute code on their machine without asking for permission first. That code ran with the same privileges as the developer—or the CI pipeline—performing the installation. In most cases, nothing happened beyond compiling a native module or downloading a binary. But from a security perspective, it also meant that compromising a single package could give an attacker an opportunity to execute arbitrary code before anyone even opened the project.
Recent supply chain attacks have shown just how valuable that opportunity is. Incidents like the compromise of popular packages such as chalk and debug, the emergence of the Shai-Hulud malware family, and later campaigns like Miasma all exploited one common assumption: that package installation was a trusted operation capable of executing code automatically. While the techniques evolved from one attack to another, the entry point remained remarkably consistent.
With npm v12, that assumption finally changes.
Rather than treating install-time code execution as the default, npm now introduces a model where developers explicitly approve which packages are allowed to execute installation scripts. It represents one of the most significant security improvements ever introduced to the npm ecosystem—not because it eliminates supply chain attacks altogether, but because it removes one of their most effective and widely abused entry points.
To understand why this change matters, however, we first need to understand how package installation actually works.
Installing a Package Has Never Been Just About Downloading Files
When npm installs a dependency, it does considerably more than fetch an archive from the registry and extract it into node_modules.
As part of the installation process, npm supports a series of lifecycle hooks that package authors can define inside their package.json. These include preinstall, install, and postinstall, each of which allows arbitrary commands to execute automatically while the package is being installed.
From the perspective of package authors, these hooks solve real engineering problems.
Libraries that depend on native code—such as bcrypt, sharp, canvas, or better-sqlite3—often need to compile C or C++ code for the developer's operating system. Others download optimized binaries for the current platform or generate files required before the package can run. Without lifecycle scripts, these packages would require developers to perform manual setup steps that are both error-prone and platform-dependent.
For years, this automation was considered one of npm's strengths. Developers could install sophisticated native libraries with a single command, while package maintainers could hide the complexity behind a seamless installation experience.
Modern JavaScript applications frequently depend on hundreds—or even thousands—of transitive dependencies. Many of these packages never appear directly in a project's package.json; they're introduced by other libraries further up the dependency tree. Yet every one of them had the same ability to execute installation-time code.
In practice, running npm install meant extending implicit trust to an enormous portion of the JavaScript ecosystem.
As the ecosystem grew, so did the attack surface.
Eventually, attackers realized they didn't need to compromise an application.
They only needed developers to install one compromised package.
Further Reading
- NodeSource: npm v12: Install Scripts Are Not a Silver Bullet