Blog

Preventing npm supply chain attacks

Published on 08.06.2026ยท6 min read
#npm #security #supply-chain #ci-cd

The npm ecosystem is under sustained attack and the cadence keeps picking up. The chalk and debug compromise in September 2025 poisoned packages with 2.6 billion weekly downloads after a single maintainer fell for a 2FA-reset phishing email. The Shai-Hulud worm self-replicated across more than 500 packages, including @ctrl/tinycolor and CrowdStrike's, by stealing tokens at install time and republishing through them. The Nx postmortem in March 2026 chained into an AWS admin takeover. The Axios incident bypassed OIDC entirely by using a stolen npm token. And the TanStack postmortem covered a May 2026 sweep that used GitHub Actions cache poisoning to steal live OIDC tokens at publish time.

The defense splits into three layers: what you decide to install, what your install tooling does to protect you, and how you stop your own releases from becoming the next incident. This post walks each one, with the release pipeline from Kubb PR #3442 as the worked example for the third.

Do you need this dependency at all?

The cheapest attack to defend against is the one where you never installed the package. Before you reach for pnpm add, ask whether the dependency should exist. Could you replace it with twenty lines of your own code? A removal test takes thirty seconds and is the only step that drops your attack surface to zero. Then look at the footprint. A package pulling in fifty transitives is fifty more chances for the next phishing attack to land on you.

Read the maintainers, not just the README

Open the GitHub repo and the Commits tab. Filter out bot noise. A two-year gap followed by a sudden burst of releases is the shape of an abandoned package that just changed hands, which is also the shape of an account takeover.

Look at who is doing the work. If almost every commit and release comes from one person, you have maintainer concentration risk. One phished maintainer is the entire incident. Healthy release cadence is monthly, quarterly, or irregular-but-consistent. What you do not want is a quiet year followed by three patch releases in a week, especially with a commit message like "fix".

Check the latest release for a provenance attestation while you are there. The badge ties the tarball back to a specific commit, repo, and workflow run. Its absence is not proof of compromise, but its presence is something an attacker without access to the maintainer's repo cannot fake.

Watch out for slopsquatting

AI coding assistants regularly hallucinate package names that do not exist, and attackers register the names ahead of time. Koos calls this slopsquatting. Any LLM-suggested package deserves a thirty-second sanity check: open it on npm, click through to the repo, and confirm a human you can name wrote the code.

Defend the install itself

Once you have decided a package is worth installing, your package manager is the next line. Mondoo's comparison for 2026 lays the differences out in detail. The headline is that pnpm 11 ships defenses the npm CLI does not.

minimumReleaseAge is the biggest single setting. pnpm 11 defaults to 1440 minutes, refusing any version published in the last day. Shai-Hulud was pulled within about twelve hours, so the default cooldown would have blocked it. Set minimumReleaseAgeStrict: true in your .npmrc if you want pnpm to fail rather than silently fall back to an older version.

strictDepBuilds is the other one. On by default in pnpm 11, it means lifecycle scripts only run for packages you explicitly list in pnpm.onlyBuiltDependencies. The legitimate native builds (better-sqlite3, sharp, esbuild) are obvious. Anything else asking to run code at install time is worth a second look. On the npm CLI you do not get any of this, so install with --ignore-scripts, keep the lockfile committed, and never run CI with --no-frozen-lockfile.

If you also publish

The other half of the picture, if you maintain something on npm, is making sure your own packages are not the source of someone else's bad day. TanStack is the cautionary case: a phishing email gave the attacker one publish token, and the worm did the rest.

The biggest single change is dropping NPM_TOKEN and switching to npm OIDC trusted publishing. GitHub mints a short-lived token per workflow run and npm verifies the signature. The publish job needs two things, lifted from Kubb's release.yml:

permissions:
  contents: write
  id-token: write
  packages: write

env:
  NPM_CONFIG_PROVENANCE: true

OIDC is not a magic bullet. The token is short-lived but it sits in the runner during the job. TanStack got hit because attackers poisoned Nx's GitHub Actions cache, the publish job restored it, and the malicious code exfiltrated the live OIDC token. Treat the Actions cache as untrusted input. Do not restore caches in the publish job. Pin every action to a 40-character commit SHA (actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6), so the chain you depend on cannot move under you between runs. Dependabot and Renovate handle the SHA bumps.

Stage releases before they hit latest. pnpm stage publish (pnpm 11.3+) splits upload from promotion, which gives you a window to catch a bad release. Kubb PR #3442 wired this in. The same idea works with release-please or a hand-rolled changesets workflow.

Keep the publish job small. Run tests, lints, and installs on earlier jobs that do not have id-token: write. Install with --ignore-scripts. Put webhook notifications on a separate job. Every extra step inside the privileged job is one more place a token can leak.

The checklist

Before installing:

  • Run the removal test. Do you actually need this package?
  • Check maintainer count, release cadence, and time since the last real commit.
  • Verify any LLM-suggested package name links to a real repo with provenance.

At install time:

  • Use pnpm 11+ for the cooldown and the strict build allowlist.
  • Set minimumReleaseAgeStrict: true and keep pnpm.onlyBuiltDependencies short.
  • Install with --ignore-scripts wherever your tooling allows it.

When you publish:

  • Enable OIDC trusted publishing and remove NPM_TOKEN from secrets.
  • Set NPM_CONFIG_PROVENANCE: true in the publish job.
  • Pin every action to a 40-character commit SHA.
  • Do not restore GitHub Actions caches in the publish job.
  • Stage releases before they hit latest.
  • Turn on two-factor auth with a hardware key on your npm account.

None of this stops a determined attacker forever. TanStack proves OIDC and provenance alone are not enough. What the layered defense buys you is that a single failure no longer ends in a poisoned release. That is the bar worth aiming for.

Portrait of Stijn Van Hulle

Stijn Van Hulle

Front-end engineer