When a developer runs npm install, they expect dependency code to sit quietly in node_modules until it's imported. Most don't realize that npm can execute arbitrary shell commands the instant the install finishes — no require(), no build step, no code review needed. That mechanism is the postinstall lifecycle script, and it has become one of the most reliable execution points for supply chain attackers targeting the JavaScript ecosystem.
The pattern is now well documented: compromise a popular package or its dependency, add a few lines to package.json, and let millions of CI pipelines and developer laptops run the payload for you. Incidents involving ua-parser-js, coa, rc, and the 2025 Shai-Hulud worm all used this same door. Below, we break down why postinstall scripts are such an attractive target, what real-world attacks have looked like, why the obvious fixes fall short, and what Safeguard does to close the gap.
What Is a Postinstall Script, and Why Does npm Run It Automatically?
A postinstall script is an arbitrary shell command defined in a package's package.json under the scripts field that npm, Yarn Classic, and (until recently) pnpm execute automatically once a package finishes installing — no confirmation prompt, no sandbox. npm supports a whole lifecycle: preinstall, install, postinstall, and prepare all fire during a normal dependency install, and they run with the same OS-level privileges as the user or CI runner invoking npm install. That means full filesystem access, full network access, and access to whatever environment variables the process inherits — API keys, cloud credentials, .npmrc tokens, SSH keys, the works.
The feature exists for legitimate reasons: compiling native addons (node-gyp), downloading prebuilt binaries (a common pattern for tools like esbuild, sharp, or Puppeteer, which needs to fetch a full Chromium binary), or generating build artifacts. The problem is that npm has no way to distinguish "download a compiler toolchain" from "download and execute a credential-stealing binary." Both look identical to the registry and to the install process: a line of shell in package.json that runs before anyone reviews the actual code.
Why Do Attackers Prefer Postinstall Scripts Over Other Injection Points?
Attackers prefer postinstall scripts because they execute before any human or automated code review happens, and because they run on every machine that installs the package — developer laptops, CI runners, and production build servers alike. Compare this to injecting malicious logic into a function that only fires under specific runtime conditions: that code has to actually be called, and a diff of the changed file might still get read by a maintainer or flagged by a scanner that inspects source. A postinstall hook, by contrast, fires unconditionally and immediately, often before the tarball's other files have even been opened by anyone.
It's also a high-leverage distribution point. A single popular package can have millions of weekly downloads, and a compromise of one of its transitive dependencies inherits that reach instantly. Attackers don't need to trick anyone into running untrusted code — the packet manager does it for them, by design, as part of a command developers run dozens of times a day. And because install scripts commonly download something (binaries, native modules, telemetry pings), a second-stage payload fetched over HTTPS at install time doesn't look anomalous in network logs the way it would coming from, say, a lodash function call.
What Have Real npm Postinstall Attacks Looked Like?
Real npm postinstall attacks have ranged from cryptomining and credential theft to a self-propagating worm, and the pattern has repeated at least once a year since 2021. In October 2021, three versions of ua-parser-js (0.7.29, 0.8.0, and 1.0.0) — a library with millions of weekly downloads used to detect browser and OS information — were published with a compromised install script that pulled down a second-stage payload to run a cryptominer and a password-stealing trojan on Windows and Linux machines. The maintainer's npm account had been hijacked, and the malicious versions were live for only a few hours, but that was enough for the payload to reach a large number of CI environments before detection.
Weeks later, in November 2021, the same pattern hit coa and rc, two widely used dependencies (coa is a transitive dependency of react-scripts, meaning it landed silently in countless create-react-app projects). Malicious versions shipped with install scripts designed to exfiltrate environment variables and credentials before npm pulled them within hours.
The most alarming evolution came in September 2025 with the Shai-Hulud campaign. Attackers compromised the popular tinycolor package and dozens of others in its dependency graph, embedding a postinstall-triggered script that ran the TruffleHog secret-scanning tool against the infected machine, harvested any cloud and npm tokens it found, and then used stolen npm publishing credentials to automatically trojanize and republish other packages the compromised developer maintained — a worm that spread from package to package with no attacker interaction required after the initial infection. It was the first large-scale demonstration that postinstall scripts could be used not just to steal data once, but to self-propagate across the registry.
Why Doesn't --ignore-scripts Solve the Problem?
--ignore-scripts doesn't solve the problem because it's opt-in, it isn't the npm default, and disabling it wholesale breaks a meaningful share of legitimate packages that rely on install-time compilation or binary downloads. npm has supported npm install --ignore-scripts (and the equivalent .npmrc setting ignore-scripts=true) for years, but almost no team enables it globally, because doing so silently breaks packages like sharp, node-sass, or anything using node-gyp — the install completes, but the package is left in a broken, half-configured state with no clear error pointing back to the missing script.
That leaves teams with an uncomfortable choice: leave scripts enabled everywhere and accept the attack surface, or disable them and manually chase down which packages actually need to run one — a task that scales poorly across a dependency tree that can easily include several hundred transitive packages for a modest application. Manually flagging "does this package have an install script, and if so, what does it actually do" is not something most teams have tooling for today, which is exactly why it keeps getting exploited: the control exists, but the operational cost of using it correctly is too high for most organizations to absorb on their own.
How Are npm, pnpm, and Yarn Responding to the Threat?
Package managers are responding by shifting from "scripts run by default" to "scripts require explicit approval," with pnpm leading the change. Starting with pnpm v10, released in January 2025, pnpm stopped running postinstall (and other build) scripts for dependencies automatically. Instead, any package that wants to run a lifecycle script during install must be explicitly allow-listed via pnpm approve-builds, and pnpm ships with a growing deny-list informed by known-malicious packages reported by the security research community. Yarn offers a comparable enableScripts configuration that teams can disable, and GitHub's npm provenance attestations (generalized in 2023) let consumers verify that a published package was built from the source it claims to come from — though provenance doesn't inspect what a script actually does at install time.
These changes are meaningful, but they're partial. npm itself, still the default installer for the vast majority of JavaScript projects, has made no equivalent default change, and even on package managers with stricter defaults, teams still have to decide what to allow-list — a decision that requires visibility into what a script does and where it sends data, which the registry itself doesn't provide.
How Safeguard Helps
Safeguard closes the gap that --ignore-scripts and manual allow-listing leave open: it inspects what a postinstall (or preinstall, install, or prepare) script actually does before it ever executes in your environment, rather than asking teams to make a blind allow/deny decision. When a new dependency or dependency update introduces a lifecycle script, Safeguard flags it, analyzes the behavior of the script and any binaries or secondary payloads it fetches, and surfaces concrete signals — network destinations, credential access patterns, obfuscation, and behavioral similarity to known malicious campaigns like ua-parser-js, coa/rc, and Shai-Hulud — so security and platform teams can make an informed call instead of guessing.
That analysis plugs directly into CI and pre-merge workflows, so a compromised transitive dependency gets caught before it reaches a build runner with cloud credentials in its environment, not after a worm has already exfiltrated them. Safeguard also continuously monitors your existing dependency tree against emerging supply chain threat intelligence, so a package that was clean at install time but gets compromised later — exactly what happened in the incidents above — triggers an alert instead of sitting silently in node_modules until the next npm audit run, if anyone remembers to run one at all.
Postinstall scripts aren't going away, and neither is the incentive for attackers to abuse them. Treating every lifecycle script as a code-review event — with actual visibility into its behavior — is the only way to keep the convenience of automated builds without inheriting their blast radius.