Skip to main content
Logo
Overview

Harden npm Against the 2026 Supply Chain Wave: A Playbook

June 20, 2026
10 min read

If you run npm install for a living, 2026 has been a bad year to not be paying attention. The attacks stopped being one-off poisoned packages and turned into self-propagating waves — worms that compromise a maintainer, harvest their tokens, and use those tokens to publish the next batch. The same crew kept coming back. And at least one attack didn’t steal a token at all; it hijacked a legitimate release pipeline mid-run and shipped malware with valid build attestations attached.

So this isn’t a “be careful out there” post. It’s a checklist. I’m going to walk through what actually happened, then give you the layers — lockfiles, cooldowns, install-script lockdown, provenance, CI blast-radius — in the order I’d put them in. None of it is exotic. Most of it ships in the package managers you already use. The hard part is just deciding to turn it on.

What actually happened in 2026

A short timeline, because the pattern matters more than any single incident.

March 31 — Axios. Someone lifted a maintainer’s npm token and pushed two poisoned versions (1.14.1 and 0.30.4). A postinstall script pulled down a cross-platform RAT. The bad versions were live for roughly four hours before npm yanked them. Axios does ~70 million downloads a week, so “four hours” was plenty. I wrote more about this one in the SCA-tool comparison.

May 11 — TanStack. This is the one that should change how you think. Between 19:20 and 19:26 UTC, 84 malicious artifacts went out across 42 @tanstack/* packages. No npm token was stolen. The attacker forked TanStack/router, abused a pull_request_target workflow (“Pwn Request”), poisoned the GitHub Actions cache across the fork-to-base trust boundary, and then extracted an OIDC token from the runner’s process memory at publish time. The result: the first npm supply chain attack carrying valid SLSA Build Level 3 attestations. Sigstore verified the build correctly, because the build itself was the thing that got hijacked.

May 19 — the AntV / echarts-for-react wave. Mini Shai-Hulud again. A compromised account (atool, which also publishes timeago.js at ~1.5M weekly downloads) was used to push 639 malicious versions across 323 packages in a roughly 20-minute automated burst. echarts-for-react alone pulls ~1.1M downloads a week. Its latest tag still pointed at the clean 3.0.6 — which protected nobody, because semver resolution picks the highest version matching your range, tag be damned. A "^3.0.6" in your package.json happily resolved to a malicious 3.2.7 on the next clean install.

There were more — compromised trivy, a Bitwarden CLI package, a June namespace incident. Researchers attribute much of this to a group tracked as TeamPCP. The throughline: the unit of attack is no longer “a package.” It’s “a maintainer account or a build pipeline,” and the worm scales from there.

Two takeaways before the defenses. First, CVE scanners are blind to this — there’s no advisory for malware that’s twenty minutes old. Second, speed is the attacker’s whole advantage. The poison is live for hours, not weeks. Almost every defense below is really about not being in the blast radius during those hours.

Layer 1 — Lockfiles, done properly

Table stakes, and yet I still see teams skip it. Commit package-lock.json (or pnpm-lock.yaml, yarn.lock). In CI, use npm ci, never npm installnpm ci installs exactly what the lockfile pins and fails if package.json and the lockfile disagree, instead of silently resolving new versions.

The lockfile gives you integrity hashes and a reproducible tree. But understand its limit: a lockfile pins what you already resolved. The first time a developer runs npm install to add or bump a dependency, resolution happens fresh — and that’s the moment a freshly-poisoned version slips in and gets written into the lockfile as if it were trusted. A lockfile is a memory, not a gate. You need a gate on new resolutions, which is the next layer.

Layer 2 — Cooldowns, the highest-leverage thing on this list

If you do one thing after reading this, do this one.

A cooldown — “minimum release age” — refuses to install any version published less than N days ago. Since basically every one of these attacks gets caught and pulled within hours to a couple of days, a 7-day cooldown means the poisoned window has closed by the time the version is even eligible to land in your tree. The Axios attack? A cooldown would have blocked it outright.

This landed across the ecosystem in 2026, and the flags differ:

  • npm (CLI 11.10.0+, the support shipped OFF by default) — in .npmrc:
    min-release-age=7
    Note min-release-age is measured in days.
  • pnpmminimumReleaseAge (measured in minutes) in config; pnpm started shipping security-by-default behavior, so check your version’s defaults.
  • Yarn 4.10+npmMinimalAgeGate.

Two gotchas worth knowing. There’s a per-invocation bypass (--ignore-min-release-age) for the “I genuinely cannot wait” moment on a laptop — forbid that flag in CI so the escape hatch stays local. And there’s a known friction with Dependabot: a cooldown can stop Dependabot from opening PRs for genuinely urgent security updates, since those are by definition brand new. Tune the window or carve out security advisories rather than throwing the whole thing out.

Is seven days too slow for you? Even a 24- or 48-hour cooldown catches the majority of these incidents, because the detection-to-takedown loop is fast now. Pick a number you can live with and ship it today.

Layer 3 — Kill install-time code execution

The RAT in the Axios attack ran from a postinstall script. That’s the standard delivery vehicle: lifecycle scripts (preinstall, install, postinstall) run arbitrary code on every machine that installs the package, including your CI runners, before a single line of your app executes.

So turn them off:

ignore-scripts=true

Here’s the honest catch, and it’s why people don’t do this: blanket ignore-scripts=true breaks anything that needs a native build step. esbuild, sharp, node-gyp-based deps, Cypress, Playwright — they all rely on a post-install step to fetch or compile a binary. Flip the switch naively and your install looks fine until something segfaults at runtime.

The fix is an allowlist. pnpm has onlyBuiltDependencies, Yarn has npmPreapprovedPackages — you keep scripts off globally and explicitly name the handful of packages permitted to run them. With plain npm it’s clunkier; you may end up running approved builds in a controlled step. Either way, the win is huge: you go from “every dependency can run code at install” to “five packages I chose can.” And as a process control, flag any PR that adds a new dependency carrying an install/postinstall script. New + runs-code-on-install is exactly the signature you want a human looking at.

GitHub has signaled npm will disable install scripts by default in a future major (v12), which will help — but defaults you don’t control aren’t a plan. Set it yourself.

Layer 4 — Provenance and account hardening

This layer splits by whether you consume packages or publish them.

If you publish, the long-lived NPM_TOKEN sitting in your CI secrets is the single worst liability you own. Steal it once, use it forever. Trusted publishing replaces it with short-lived OIDC: npm only accepts a publish that actually originated from your configured CI job, on the configured workflow and tag. No standing token to lift. Combine that with provenance attestation so consumers can verify a package was built where it claims, and lock every maintainer account behind phishing-resistant 2FA. npm also shipped bulk OIDC configuration in 2026, so wiring this across many packages is less painful than it used to be.

Now the uncomfortable asterisk: the TanStack attack produced valid attestations. Provenance proves a package came from the expected pipeline; it does not prove that pipeline wasn’t compromised. So provenance is necessary and worth doing — just don’t treat a green attestation as proof of safety. It raises the bar; it isn’t a force field. Which leads directly to the layer most teams underinvest in.

Layer 5 — CI/CD blast-radius control

TanStack is the case study for why this matters. The attacker never touched npm credentials directly. They walked in through GitHub Actions: a pull_request_target workflow that ran with elevated permissions on untrusted fork code, a poisoned Actions cache crossing the fork/base trust boundary, and OIDC token extraction from runner memory. Your CI is your supply chain now. Harden it like production.

Concretely:

  • Never run untrusted code with secrets in scope. pull_request_target and workflow_run are the sharp edges — they grant the base repo’s permissions to workflows triggered by forks. If you must use them, don’t check out and execute PR code in the same job that holds tokens.
  • Scope tokens to the minimum. Per-package, per-job, least-privilege. A token that can publish one package is a far smaller fire than one that can publish your whole org.
  • Egress control on runners. Tools like StepSecurity’s Harden-Runner let you allowlist outbound network destinations from a runner, so an exfil call to an attacker endpoint gets blocked and logged instead of silently succeeding.
  • Treat the Actions cache as untrusted across trust boundaries. Cache poisoning across fork/base was load-bearing in the TanStack chain.
  • Rotate secrets on any suspicion, and assume runner memory is readable by anything you let execute on the runner.

This is the least convenient layer because it touches workflows people are afraid to change. Do it anyway. It’s where the most sophisticated 2026 attacks actually landed.

Behavioral scanning vs CVE matching

Your default tooling — npm audit, Dependabot, most “SCA” products — matches your dependencies against databases of known vulnerabilities. Against a worm that’s twenty minutes old, that’s worth roughly nothing. There’s no CVE yet.

What catches fresh malware is behavioral analysis: does this package suddenly contain obfuscated code, a new network call, a postinstall that reads ~/.npmrc or your env? That’s the niche Socket-style tooling fills, and it’s a genuinely different job from CVE matching. I went deep on the trade-offs in the Socket vs Snyk vs Aikido comparison, and if your AI agent is the thing running installs unsupervised, the slopsquatting writeup covers that angle. Short version: a behavioral scanner is a good layer, not a substitute for the cheap structural defenses above. Don’t buy a scanner and skip the .npmrc.

The copy-paste hardening checklist

A starting .npmrc for a consuming project:

# refuse versions younger than 7 days (npm 11.10.0+)
min-release-age=7
 
# no lifecycle scripts on install...
ignore-scripts=true
# ...then allowlist the few that legitimately need a build,
# via pnpm onlyBuiltDependencies / Yarn npmPreapprovedPackages

And the broader list:

  1. Commit your lockfile; use npm ci in CI, never npm install.
  2. Set a cooldown (min-release-age / minimumReleaseAge / npmMinimalAgeGate). Forbid the bypass flag in CI.
  3. ignore-scripts=true + an explicit build allowlist. Flag new deps with install scripts in PR review.
  4. Publishing? Trusted publishing (OIDC) instead of long-lived NPM_TOKEN, plus provenance and 2FA on maintainer accounts.
  5. Lock down CI: least-privilege tokens, egress control, no untrusted code in secret-bearing jobs, treat the cache as untrusted across fork boundaries.
  6. Add a behavioral scanner for fresh-malware detection — as a layer, not the whole plan.

If your team only adopts items 1 through 3 this week, you’ve already closed the window on the way the overwhelming majority of 2026’s attacks reached their victims. Start with the cooldown — it’s one line, it’s reversible, and it would have stopped most of the names in this post. Then go pick the next dependency you’re about to add and ask yourself how old that version actually is.