Supply-chain attacks stopped being a hypothetical in 2026. They’re now CISA alerts, named threat actors, and poisoned packages with millions of weekly downloads. The incident that made it concrete for me: axios, with roughly 100M weekly downloads, had malicious versions 1.14.1 and 0.30.4 live on the registry for about three hours on March 31, 2026 — long enough for thousands of npm installs to pull them. The command-and-control was attributed to Sapphire Sleet, a North Korean state actor, and CISA issued an alert on April 20, 2026. The mechanism was almost embarrassingly simple: a single change to a package’s package.json that injected a malicious dependency.
If that didn’t land, look at the rest of the 2026 scoreboard: the Mastra ecosystem had 140+ poisoned packages (June 2026); a Red Hat @redhat-cloud-services campaign hit 32 packages in one push (June 1, 2026); 454,648 malicious npm packages were published in 2025; the Shai-Hulud worm infected 796 packages; and a September 2025 hijack affected packages with 2 billion weekly downloads combined.
The takeaway is not “be careful.” The takeaway is: assume the registry is hostile, and let your build pipeline be the perimeter. Human vigilance doesn’t scale to a tree of transitive dependencies; policy, pinning, and CI gates do.
The anatomy of the axios attack
Read the incident closely and the scary part is how small it was. This wasn’t a 5,000-line backdoor dropped into the axios source. It was a single package.json change that added a malicious dependency. Because axios is on virtually every frontend, npm install axios@1.14.1 pulled that extra package into a huge number of build trees in an afternoon.
Three vectors keep showing up across the 2026 incidents, and they map to how npm actually works:
- Compromised maintainer / package accounts. The attacker doesn’t need to break into your repo. They need to break into one maintainer’s npm account (or a package’s), then publish a poisoned version under a name you already trust. Shai-Hulud and the Sep 2025 hijack are exactly this.
postinstall/ lifecycle-script payloads. A dependency can run arbitrary code at install time. This is the classic “the package looks fine but itspostinstalldoes something” vector — and it’s whynpm audit(which flags known vulnerabilities) is not the same thing as blocking untrusted scripts.- Transitive dependency chains. You don’t depend on the malicious package directly. You depend on A, which depends on B, which depends on the poisoned C. Your
package.jsonlooks clean; your lockfile is the thing that’s actually exposed.
That last point is the one most teams get wrong. npm audit tells you about known, disclosed vulnerabilities in your tree. It does not tell you that a newly published version of a package your transitive dependency pulls in is fresh, malicious, and has no CVE yet. In 2026, the window between “malicious version published” and “audited” is minutes to hours — which is precisely the window the axios versions sat in.
Locking down your npm supply chain
Defense-in-depth, in order of how much it actually helps:
1. Audit + pin: make the tree deterministic
You can’t defend a tree you can’t see. Force the exact, locked set of packages everywhere:
# 1) See the full resolved tree (direct + transitive)
npm ls --all
# 2) Clean, reproducible install from the lockfile ONLY (use in CI)
npm ci
# 3) Gate on high-severity *known* vulns (not a substitute for pinning)
npm audit --audit-level=high
npm ci is the one that matters in CI: it deletes node_modules and installs exactly from package-lock.json, so nobody can drift a version in between a green local build and the runner. If your lockfile is the single source of truth, a malicious transitive can only reach you if it’s in the lockfile — and that’s something you can review, pin, and block.
2. Hardened package.json: engines, overrides, and blocked scripts
package.json is more than a dependency list. Use it to constrain the environment and force specific versions of transitive deps, and — critically — to stop lifecycle scripts from running code you never vetted:
{
"name": "my-hardened-app",
"version": "1.0.0",
"engines": {
"node": ">=18.0.0 <21.0.0",
"npm": ">=9.0.0 <11.0.0"
},
"dependencies": {
"axios": "^1.14.0"
},
"overrides": {
"axios": "1.14.2"
},
"resolutions": {
"axios": "1.14.2"
},
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}
enginespins the Node/npm range so a build can’t silently run on an old, vulnerable toolchain.overrides(npm ≥ 8.3) /resolutions(Yarn) force a specific version of a dependency even transitively. This is how you pinaxiosto a known-good1.14.2even when some other package wants the poisoned range.- Blocking lifecycle scripts is the big one. A malicious
postinstallis the payload delivery mechanism in a large share of these incidents. For a managed install, the strongest option is to not run install scripts at all and opt-in only for packages you’ve vetted (pnpm makes this explicit with its script-approval model;--ignore-scriptsis the blunt version).
A before/after of the pattern to watch for in a dependency you do allow:
// BEFORE — the package's own lifecycle script runs arbitrary code at install
"scripts": { "postinstall": "node install.js" }
// AFTER — you've reviewed install.js (or replaced the package), and the
// script is now an explicit, audited, no-op-or-verified step
"scripts": { "postinstall": "node ./verified/install.js" }
3. CI supply-chain gate: provenance + audit + pin, in one job
Your pipeline is the last line of defense before a build ships. Make it fail loudly on any of the three vectors above — unknown transitive, high-severity vuln, or a package with no provenance:
name: Supply Chain Security Gate
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
supply-chain:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Reproducible install from lockfile
run: npm ci --ignore-scripts
- name: Fail on high-severity known vulns
run: npm audit --audit-level=high
- name: Verify package provenance (SLSA)
uses: slsa-framework/slsa-github-generator/.github/actions/generator@v1.2.0
with:
generate-builder: true
- name: Block unauthorized lifecycle scripts
run: npm ls --all --json | node scripts/assert-no-untrusted-postinstall.js
Two details do most of the work here:
npm ci --ignore-scripts— install the locked tree without running any package’s lifecycle scripts. Any package that needs a real build step must be an explicit, reviewed exception. This is the direct counter to thepostinstall-payload vector.- Provenance (SLSA) — verify the package was built and published by the source you expect. That’s your defense against the axios case, where the source itself was compromised: a version that can’t prove its origin should not enter the build.
Pair this with Renovate or Dependabot set to require review on every dependency bump — especially majors — and on major registries, prefer private registry mirrors with their own scanning so you’re not trusting the public registry’s availability as a security control.
4. Triage: which dependencies do you actually trust?
Tools get you 80% there. The other 20% is a human judgment call on your direct dependencies. For each one, ask:
- Do I actually need this, or is it a 3-line utility I could inline? (Prune.)
- Is it actively maintained with a real security posture (2FA on the npm account, signed releases)?
- Has it had a past incident? (Shai-Hulud, the Sep 2025 hijack — check the name against the 2026 lists.)
- Can I replace it with a smaller, more focused, better-vetted alternative?
For your truly critical transitive deps, the strongest move in 2026 is vendoring the exact version (pinning it in your own repo) or routing it through your private registry mirror so you control exactly what bytes ship.
The bottom line
The npm registry is now an active attack surface, and the 2026 incidents — axios, Mastra, Red Hat, Shai-Hulud — all exploit the same gap: a package you already trust, a version you didn’t choose, a script you never reviewed. You don’t fix that with vigilance. You fix it with policy + pinning + CI gates: a deterministic lockfile installed with npm ci, lifecycle scripts blocked by default, provenance verified in the pipeline, and a human triage pass on the deps you actually depend on. Assume the registry is hostile. Your build is the new perimeter — make it the thing that decides what ships.