Preventing npm supply chain attacks
A new npm compromise lands every few weeks, and each one follows the same: steal a maintainer's credentials, publish a poisoned version, let the registry fan it out to everyone before anyone notices.
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.w
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 npm i, yarn add or pnpm add, ask whether the dependency should exist. Could you replace it with twenty lines of your own code or even use AI to generate the code?
Then look at the footprint. A package that pulls in fifty others adds fifty more maintainers who can get phished. Fewer dependencies, smaller attack surface.
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 weekly, monthly, quarterly. What you do not want is a quiet year followed by three patch releases in a week.
Check the latest release for a provenance attestation. The badge ties the tarball back to a specific commit, repo, and workflow run. A missing badge is not proof of compromise. But an attacker who does not control the repo cannot fake one.
Watch out for slopsquatting
Slopsquatting is a supply chain attack where hackers register fake, malicious software packages that AI models frequently hallucinate, tricking developers or AI agents into installing them.
AI coding assistants regularly hallucinate package names that do not exist, and attackers register the names ahead of time. This is called slopsquatting or hallucination squatting.
Any LLM-suggested package deserves a thirty-second sanity check: open it on npm, click through to the repo, and confirm the name maps to a real project.
Installing
Once you have decided a package is worth installing, your package manager is the next line of defense. pnpm 11 ships defaults that npm does not, so the focus here is pnpm.
minimumReleaseAgeis the biggest single setting. pnpm 11 defaults to 1440 minutes, refusing any version published in the last day.
TIP
npm has its own min-release-age setting for the same job, added in npm 11.10.0.
strictDepBuildsis the other one. On by default in pnpm 11, it means lifecycle scripts only run for packages you explicitly list inpnpm.onlyBuiltDependencies. Keep the lockfile committed, and never run CI with--no-frozen-lockfile.
TIP
Npm does not have this, but you can set --ignore-scripts to disable all scripts.
In pnpm 11 these settings live in pnpm-workspace.yaml:
# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
onlyBuiltDependencies:
- esbuild
On npm, put the cooldown in .npmrc:
# .npmrc
min-package-age=72
ignore-scripts=true
Publishing
The other half of the picture, if you maintain something on npm, is making sure your own releases cannot be hijacked.
- The biggest single change is dropping
NPM_TOKENand switching to npm OIDC trusted publishing. GitHub mints a short-lived token per workflow run and npm verifies the signature. - OIDC is not a magic bullet. The token is short-lived, but it still sits in the runner during the job. TanStack got hit this way: attackers poisoned Nx's GitHub Actions cache, the publish job restored it, and the malicious code read the live OIDC token. So treat the Actions cache as untrusted, and do not restore caches in the publish job.
- Pin every action to a 40-character commit SHA (
actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6) so a dependency cannot change under you between runs. - 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. - Keep the publish job small. Run tests, lints, and installs on earlier jobs that do not have
id-token: write. Every extra step inside the privileged job is one more place a token can leak. The publish job needs a couple of things, lifted from Kubb's release.yml:
permissions:
contents: write
id-token: write
packages: write
env:
NPM_CONFIG_PROVENANCE: true
Staged publishing
Without staging, a compromised CI runner publishes straight to the latest tag. The bad version reaches everyone running npm install or pnpm i within minutes. Staged publishing breaks that path in two.
- First, CI uploads.
pnpm stage publishsigns the release and pushes it to npm as a staged version that no one can install yet. - Second, a human promotes it. A maintainer reviews the staged version and moves it to
latest, and npm requires 2FA for that step.
That split is what saves you. An attacker who steals your tokens from a poisoned workflow still cannot promote the release. The 2FA prompt lands on your hardware key or authenticator, not the runner. The release sits in staging until someone with the key approves it, and that is your window to catch it and abort.
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: trueand keeppnpm.onlyBuiltDependenciesshort. - Install with
--ignore-scriptswherever your tooling allows it.
When you publish:
- Enable OIDC trusted publishing and remove
NPM_TOKENfrom secrets. - Set
NPM_CONFIG_PROVENANCE: truein the publish job. - Pin every action to a 40-character commit SHA.
- Do not restore GitHub Actions caches in the publish job.
- Stage releases via staged publishing 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. The point of layering it is simpler: one failure no longer ends in a poisoned release.