Why supply-chain attacks dominate the JavaScript ecosystem
A small npm install pulls in a thousand-odd packages from hundreds of strangers, and some of that code runs before you type anything. JavaScript's deep, trusting dependency graph is the attack surface, and every step of the attack is a feature someone shipped on purpose.
On this page
The picture version
Six pictures for a reader who has run npm install without ever wondering what
it just did. The prose below fills in the seams the pictures skip.
1 · The problem
One command, a thousand strangers, code already running.
2 · What “I trust this package” means
You picked a handful. You got the closure.
3 · The path in
Four steps, and not one of them meets a gate.
4 · The 2025 upgrade
Stop phishing maintainers one at a time.
5 · What actually helps
Four partial fixes, each with the hole named.
6 · Keep this card
The whole thing on one index card.
Why it exists
You clone a small side project, run npm install (or pnpm install), and watch a wall of progress bars scroll past. When it stops, your node_modules directory holds something like 1,200 packages — the exact number varies by project, the order of magnitude doesn’t — written by hundreds of strangers, most of whom you’ve never heard of. You didn’t read any of the code. Some of those packages already ran install-time scripts on your machine, with your permissions, while you watched the progress bars — and the rest will execute as soon as your build or dev server imports them. None of this struck you as weird, because this is just how JavaScript projects work.
Hold onto that install — the 1,200 packages, the strangers, the code that ran before you typed anything. It’s the running example for the rest of this post, and it is the threat model. The JavaScript ecosystem carries one of the deepest dependency graphs in mainstream software — the standard library is famously thin, the package manager is famously easy, and the culture rewards micro-packages. A single click-to-install can transitively pull in code from people who didn’t even know your project existed. That density is the surface attackers go after, and they go after it because it works.
A few real incidents map this out. In 2018 a maintainer of the widely-used event-stream package — by his own admission, burned out and no longer interested — accepted an offer of help from a stranger calling themselves right9ctrl and handed over publish rights. The new “maintainer” added a dependency, flatmap-stream, with an encrypted payload that activated only inside the Copay Bitcoin wallet build and targeted wallets holding above a threshold balance. By npm’s own account the poisoned dependency went in on 9 September 2018 and its security team wasn’t notified until 26 November — two and a half months sitting in the registry, being installed. In October 2021 the widely-depended-on ua-parser-js package had three versions published with a cryptominer and credential stealer after the maintainer’s npm account was taken over; the bad versions were live for a matter of hours, which was plenty. In March 2022 the maintainer of node-ipc — widely pulled in as a transitive dependency — shipped a payload that wiped files on machines geolocated to Russia or Belarus as protest against the invasion of Ukraine. Same surface, different motive.
The unifying shape across all three: no gate in the supply chain stopped any of them. Each was caught after the fact, by users and researchers, not by anything standing between publish and install. Trusted package, hijacked (or self-sabotaging) maintainer, automatic upgrade — and your build is now running their code.
Why it matters now
The pace has accelerated, not slowed. In September 2025 a worm called Shai-Hulud appeared on npm. Researchers across several vendors characterized it as the first self-replicating worm to spread through npm itself. The mechanism is the part that matters: when a developer with a compromised npm token installs an infected package, the worm uses the token to enumerate every other package that developer maintains and publishes a poisoned version of each. It also sweeps the machine for whatever credentials it can find — registry tokens, source-host tokens, cloud keys — using TruffleHog, and exfiltrates them. (Which credentials show up in a given write-up varies; the sweep is the constant.) By the time the first wave was being characterized, it had hit ~180 packages; a second wave dubbed “Shai-Hulud 2.0” in November 2025 moved its payload to the preinstall hook — firing earlier in the install than the postinstall hooks the first wave used — and is reported to have generated tens of thousands of malicious GitHub repos as a side-effect of credential exfiltration. The shape is well-sourced — npm worm, token-driven self-propagation, secret harvesting. The counts are not stable numbers: each wave was still being enumerated while researchers published, so package and victim totals differ between write-ups and between the day they were written and the day you read them.
The Python and Ruby ecosystems have had similar incidents — typo-squats, malicious sdists, hijacked PyPI accounts — but they don’t dominate the security conversation the way npm’s do. Graph depth is the usual explanation and it fits the incidents, but it is an explanation rather than a measured finding — treat it as the best available account, not a settled one. Go is a useful contrast: its standard library is broad enough that idiomatic projects vendor far fewer dependencies, and there is no postinstall hook running arbitrary code on your machine. That isn’t a security feature on purpose so much as a culture difference, and the culture difference is the security delta.
If you ship JavaScript in 2026, this is the part of your attack surface you have the least visibility into — thousands of lines of executable code you never read, updated by people you can’t contact. Not your code. Theirs.
The short answer
supply-chain attack = trusted package + hijacked maintainer/token + automatic upgrade
Picture to keep: your 1,200-package install as a delivery pipe with 1,200 inlets, each one controlled by a stranger’s login — and the pipe refills itself every time CI runs. The picture breaks in one important way: what comes down the pipe isn’t inert cargo, it’s code that runs on arrival, during install and again at import.
A supply-chain attack doesn’t break your code; it breaks the path by which someone else’s code becomes your code. The attacker takes over a package you (or a package you depend on, or a package that package depends on) already trust, publishes a new version, and waits for the world’s ^1.2.3 version ranges and automatic rebuilds to pull it down and run it.
How it works
The cleanest way to see the mechanism is to try to reach that 1,200-package install yourself, and watch each naive attempt fail into a better one. (This is the attacker’s derivation, and it’s exactly why the defenses further down are shaped the way they are.)
Attempt 1: publish a malicious package and wait. You write super-fast-json, put malware in it, publish. Nobody installs it. The registry has millions of packages and no one is browsing; installs come from someone typing a name they already know.
Fix: take a name people already type. Register lod4sh and hope for a typo — that’s typosquatting. It works, but your yield is capped at the share of developers who mistype, which is small. You need a name people type on purpose.
Fix: take over a package people already trust. Now the graph becomes your friend. A modern project’s node_modules holds hundreds to low thousands of packages, and most are transitive: pulled in by something you pulled in, two or three levels deep. The causes are real and individually defensible — a culture of small composable packages (is-odd, left-pad, is-number), a thin standard library, a registry where publishing a 12-line module is free. The cost is that “I trust this package” actually means “I trust this author, plus the closure of every author of every package they depend on, recursively.” So you don’t need a famous package. You need any package inside that closure — and there are hundreds of candidates whose maintainer is one burned-out volunteer.
But you’d need publish rights. Publish access on npm is gated by an account or a token, and there is no human review step between “maintainer pushes a version” and “your pnpm install runs it” — the registry is a publish-and-go medium. So: phish a login page that looks like npmjs.com, scrape a token out of a CI log or a compromised laptop, or just ask — event-stream was handed over to a stranger who volunteered to help. In the ua-parser-js case the maintainer reported his npm account hijacked, noticing when his inbox filled with spam; the public record stops there and doesn’t say how the credentials were obtained.
But people would have to upgrade for your version to reach them. They will, automatically. package.json ranges like ^1.2.3 mean “any compatible 1.x”, so the first time a fresh CI runner resolves that range it takes the latest matching version on the registry. Lockfiles pin what you already installed — but a brand-new clone with no lockfile, a pnpm update, or a Dependabot PR will happily pick up the version published 30 seconds ago.
But your code still has to actually run. It does, before anyone imports anything. Packages can declare preinstall, install, and postinstall scripts that execute on the developer’s machine and in CI, with the developer’s full permissions. This is how node-gyp builds native bindings; it is also how malware ships. Shai-Hulud used these hooks, and the 2.0 variant moved earlier in the lifecycle specifically to dodge mitigations aimed at the later ones.
And the last upgrade: don’t phish maintainers one at a time. Everything above still costs the attacker one phish per package. A worm removes that cost. Land on a machine, harvest the npm token sitting there, enumerate every package that victim maintains, republish all of them poisoned — each new victim becomes the next attacker. That’s the Shai-Hulud shape, and it’s why 2025 felt different from 2018.
Stack the chain together and the attack writes itself: enormous transitive graph, accounts as the only gate, automatic upgrade as the delivery mechanism, install scripts as the trigger. Every step is a feature someone shipped for a good reason.
Defenses, with the seams
There is no clean fix. There are several partial fixes, each with a known weakness.
- Lockfiles (
package-lock.json,pnpm-lock.yaml,yarn.lock). Pin every transitive dependency to an exact version + integrity hash. This stops silent upgrades — once you’ve installed a version, your build will keep getting that exact tarball. The seam: lockfiles don’t help against a malicious version you haven’t installed yet. The first developer or CI job to resolve a fresh range still gets whatever’s latest. - Disable lifecycle scripts.
npm install --ignore-scripts, or pnpm’s build allow-list (onlyBuiltDependenciesin the pnpm 10 line this repo pins; check your own version’s docs, the setting has moved), prevent untrusted packages from running code at install time. This repo’spnpm-workspace.yamlrestricts builds toesbuildandsharpfor exactly that reason. The seam: lots of packages legitimately need build steps, the allow-list has to be maintained, and blocking install hooks doesn’t stop malicious code in a package’s main module from running the moment your app imports it. - Release-age delays. pnpm’s
minimumReleaseAgesetting refuses to install any version published more recently than a configured window. The bet is that yanked malicious releases are usually identified within days of going up, so a delay buys you free immunity to most acute attacks. The CLAUDE.md for this repo names this explicitly: it setsminimumReleaseAge: 10080— 7 days — as a deliberate defense against Shai-Hulud-style worms. The seam: it’s a bet, not a guarantee. The 2.5-month dwell time ofevent-streamwould have laughed at a 7-day window. - Provenance and signing. npm provenance attestations and Sigstore let publishers attach a verifiable claim that “this tarball was built from this commit on this CI runner.” This makes some classes of attack harder — a stolen npm token alone can’t forge a CI provenance — and gives auditors something to grep. The seam: it’s only as good as the runner’s secrets hygiene and the consumer’s willingness to check. Most installs don’t.
- Vendoring and minimal dependencies. The Go ecosystem’s culture is openly different: a broad standard library, fewer-but-larger dependencies, no install-time hook, and easy first-class vendoring (
go mod vendor— opt-in, not the default, but a normal thing to do). You can run a JavaScript project that way too — pin everything, audit additions, prefer one big well-known package to a thicket of small ones. The seam: you’re swimming against the river. Most JS tooling assumes the dense graph.
The honest line is that none of this makes you safe. It makes you less likely to be patient zero — and, if you’re patient one or two, it shrinks the time window during which the bad version reaches your build. The structural fix would be a culture shift toward fewer, larger, more-vetted dependencies, and that is not happening. The dense graph is what the ecosystem is.
So you compose the partial fixes — lockfiles, script restrictions, release-age delays, provenance checks where you can get them — and you shift the question from “am I safe” to ones you can actually answer: how many packages could execute code on my CI runner today, which of them would I notice changing, and how long would a bad version have to survive before my install picked it up?
You started this post with supply-chain attack = trusted package + hijacked maintainer/token + automatic upgrade. What did the derivation add that isn’t in that line? — + the transitive closure, which is the part that turns one compromised volunteer into your problem. You never trusted the package that got hijacked. You trusted something that trusted something that trusted it.
Check yourself
Before you go — your CI has a committed lockfile and runs pnpm install --frozen-lockfile, so no version can change without a PR. A dependency four levels down gets hijacked today. Are you safe?
Answer
For the existing build, mostly yes: a frozen lockfile pins exact versions and integrity hashes, so today’s malicious release isn’t what gets installed. But the lockfile is a snapshot, not a policy. The moment someone opens a dependency-bump PR, adds a new package whose range resolves that dependency freshly, or CI regenerates the lockfile, you resolve against the registry again and can pick the poisoned version up. Lockfiles buy you time, not immunity — which is exactly the bet minimumReleaseAge makes explicit.
And one more — if lifecycle scripts are the trigger, why doesn’t --ignore-scripts end the whole problem?
Answer
Two reasons. First, install scripts are the convenient trigger, not the only one: malicious code sitting in a package’s main module runs as soon as your app imports it, and in CI that’s usually seconds later during the build or test step. Second, blocking scripts breaks real packages that need to compile native code, so teams end up maintaining an allow-list — and an allow-listed package is exactly the one worth hijacking. It narrows the window; it doesn’t close the path.
Famous related terms
- Typosquatting —
typosquat = malicious package + name that looks like a real package—lod4shinstead oflodash. Cheaper than hijacking, relies entirely on a developer’s typo or autocomplete miss. - Dependency confusion —
dep confusion = public package + same name as your private one + higher version number— Alex Birsan’s 2021 trick that smuggled code into Apple, Microsoft and others by exploiting registry-resolution order. postinstallscript —postinstall = arbitrary command + runs on every npm install— the load-bearing footgun, and the classic trigger for malicious packages. Its siblingspreinstallandinstallfire earlier, which is where Shai-Hulud 2.0 moved.- Lockfile —
lockfile = exact-version pins + integrity hashes for every transitive dependency. Reproducibility tool that doubles as a partial defense. - npm provenance —
provenance = signed attestation that this tarball came from this commit + this CI runner, backed by Sigstore’s transparency log. Defends against pure token theft, not against a maintainer who builds the malicious version themselves. - Shai-Hulud —
Shai-Hulud = npm worm + stolen maintainer token + auto-republish of every other package the victim owns. The 2025 incident that motivated this whole post.
Going deeper
- npm Inc.’s post-mortem on the event-stream incident — the first-party account of how a handover of publish rights actually played out, if you want the primary record rather than a summary.
- Alex Birsan’s Dependency Confusion — the best end-to-end explainer of how registry resolution order becomes an attack, written by the person who ran it against Apple and Microsoft.
- Datadog Security Labs’ Shai-Hulud 2.0 analysis — the rabbit hole: what a self-replicating registry worm looks like in detail, and how fast the numbers moved while researchers were writing.
Two notes on what the public record does and doesn’t settle. The incidents above (event-stream 2018, ua-parser-js 2021, node-ipc 2022, Shai-Hulud 2025) are documented in first-party post-mortems and vendor analyses, and so is the structural mechanism — transitive graph, automatic upgrade, install scripts, maintainer-account trust. The scale of Shai-Hulud is not a settled number: researchers were publishing revised counts while each wave was still running, definitions of “affected package” differ between write-ups, and any figure quoted here would be a snapshot of one report on one day. Read the counts as “hundreds to tens of thousands depending on the wave and the definition,” and follow the linked analyses for current figures.