Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

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.

Security intermediate May 2, 2026 · updated Aug 25, 2026 · 14 min read

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.

pnpm install one command ~1,200 packages on your disk from hundreds of authors you read the code of 0 of them and some of it has already executed install-time scripts ran while the progress bars scrolled, with your permissions the rest runs the moment your build or dev server imports it, which is usually seconds later This is not a mistake anyone made. It is how the ecosystem works.
The exact count varies by project; the order of magnitude doesn’t. Every one of those packages is executable code with your user’s permissions, and no human read any of it between the publisher pressing send and your machine running it.

2 · What “I trust this package” means

You picked a handful. You got the closure.

your project you chose thisand thisand thisand this hundreds of packages nobody on your team ever chose The attacker doesn’t need a famous package. Any login in that pile will do.
“I trust this package” really means “I trust this author, plus every author of every package they depend on, recursively.” The graph is the trust decision, and hundreds of the people in it are unpaid volunteers with an npm password.

3 · The path in

Four steps, and not one of them meets a gate.

get the login phish it, steal a token from a log or laptop, or volunteer to help publish a version no human reads it between your push and their install wait zero days ^1.2.3 means “whatever is newest right now” and it runs preinstall fires on the machine doing the installing where you might expect a checkpoint, there is a feature accounts as the only gate — so that maintainers can ship without asking anyone automatic upgrades — so that security fixes reach everybody without a pull request install scripts — so that packages with native code can compile themselves Every step of the attack is a convenience somebody shipped on purpose.
None of these three was a security oversight; each solves a real problem for maintainers and users. The attack is what they compose into — which is why the defences further down are all partial and all annoying.

4 · The 2025 upgrade

Stop phishing maintainers one at a time.

before: one phish buys one package after: one victim buys all of theirs, then finds the next one phish → one package infected install runs on a maintainer’s machine finds the token npm token, plus any cloud keys lying about republish everything they own every package that maintainer owns, poisoned, in one go and their users are the next machines Self-replication removes the attacker’s per-package cost. That is what changed.
This is the Shai-Hulud shape, and it is the reason 2025 felt different from 2018. Nothing in the mechanism is new — stolen token, automatic upgrade, install hook — it is the same chain, running itself.

5 · What actually helps

Four partial fixes, each with the hole named.

what it stops what it doesn’t lockfiles silent upgrades of what you already have the first resolve of a fresh range still takes whatever is newest no install scripts code running before you import anything malicious code in the main module still runs when your build imports it release-age delay versions published in the last few days a payload that sits quietly for months walks straight through the window provenance a stolen token forging a build only if the consumer checks it, and most installs never do None of these makes you safe. Together they make you late, not first.
Being late is worth more than it sounds: the bet behind a release-age delay is that malicious versions are usually spotted within days, which turns “patient zero” into someone else’s incident report. It is a bet, not a guarantee — event-stream sat in the registry for two and a half months. The one thing no setting buys is a smaller graph.

6 · Keep this card

The whole thing on one index card.

supply-chain attack = trusted package + hijacked maintainer/token + automatic upgrade + the transitive closure the ingredient that turns one burned-out volunteer’s npm password into your production build You never trusted the package that got hijacked. You trusted something that trusted something that trusted it. so the useful question isn’t “am I safe” but “how long would a bad version survive before I installed it”
Every defence in the post is an answer to that second question, not the first. The structural fix — fewer, larger, better-vetted dependencies — is a culture change, not a setting, and it is not the direction the ecosystem is moving.

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.

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.

Going deeper

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.