---
title: "Preventing npm supply chain attacks"
description: "How to evaluate npm packages, protect your install pipeline, and harden releases against supply chain attacks."
canonical_url: "https://stijnvanhulle.be/blog/preventing-npm-supply-chain-attacks"
---

> Index of every page on this site: [llms.txt](https://stijnvanhulle.be/llms.txt)

A new npm compromise lands every few weeks. The pattern is familiar: steal a maintainer's credentials, publish a poisoned version, and let the registry distribute it before anyone notices.

<div className="doc-viz" dataAnimate="true">

How one compromise reaches everyone

<div className="dv-flow,atk-track">
<div className="dv-node,dv-node--danger" style="--i:0">

Phished maintainer

</div>

<div className="dv-arrow" style="--i:1">



</div>

<div className="dv-node,dv-node--danger" style="--i:2">

Poisoned version

</div>

<div className="dv-arrow" style="--i:3">



</div>

<div className="dv-node" style="--i:4">

npm registry

</div>

<div className="atk-pulse">



</div>
</div>

<div className="atk-fan">
<div className="atk-dot" style="--i:0">



</div>

<div className="atk-dot" style="--i:1">



</div>

<div className="atk-dot" style="--i:2">



</div>

<div className="atk-dot" style="--i:3">



</div>

<div className="atk-dot" style="--i:4">



</div>

<div className="atk-dot" style="--i:5">



</div>

<div className="atk-dot" style="--i:6">



</div>

<div className="atk-dot" style="--i:7">



</div>

<div className="atk-dot" style="--i:8">



</div>

<div className="atk-dot" style="--i:9">



</div>

<div className="atk-dot" style="--i:10">



</div>

<div className="atk-dot" style="--i:11">



</div>

<div className="atk-dot" style="--i:12">



</div>

<div className="atk-dot" style="--i:13">



</div>

<div className="atk-dot" style="--i:14">



</div>

<div className="atk-dot" style="--i:15">



</div>
</div>

One stolen login publishes a poisoned version, and the registry pushes it to every install before a human catches it.

</div>

The [`chalk` and `debug` compromise](https://semgrep.dev/blog/2025/chalk-debug-and-color-on-npm-compromised-in-new-supply-chain-attack/) 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](https://www.sysdig.com/blog/shai-hulud-the-novel-self-replicating-worm-infecting-hundreds-of-npm-packages) 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](https://nx.dev/blog/nx-console-v18-95-0-postmortem) in March 2026 chained into an [AWS admin takeover](https://thehackernews.com/2026/03/unc6426-exploits-nx-npm-supply-chain.html). The [Axios incident](https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/) bypassed OIDC entirely by using a stolen npm token. And the [TanStack postmortem](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem) covered a May 2026 sweep that used GitHub Actions cache poisoning to steal live OIDC tokens at publish time.

The defense has three layers: choose dependencies carefully, configure your install tooling, and protect your own release process.

## Do you need this dependency at all?

The cheapest attack to defend against is the one where you never install the package. Before you run `npm i`, `yarn add`, or `pnpm add`, ask whether you need the dependency. Could a few lines of local code solve the problem?

Then check the dependency footprint. A package that pulls in fifty others adds fifty more maintainers who could be phished. Fewer dependencies mean a smaller attack surface.

## Read the maintainers, not just the README

Open the GitHub repository and inspect the Commits tab. Filter out bot activity. A two-year gap followed by a sudden burst of releases can signal an abandoned package that changed hands, or an account takeover.

Look at who does the work. If almost every commit and release comes from one person, there is a concentration risk: one phished maintainer can compromise the whole project. A quiet year followed by several patch releases in one week deserves a closer look.

Check the latest release for a [provenance attestation](https://docs.npmjs.com/generating-provenance-statements). It ties the tarball to a specific commit, repository, and workflow run. A missing attestation does not prove a compromise, but an attacker without control of the repository cannot create a valid 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 sometimes suggest package names that do not exist. Attackers register those names ahead of time, a practice called `slopsquatting` or `hallucination squatting`.
Any LLM-suggested package deserves a thirty-second sanity check: open it on [npm](https://www.npmjs.com), click through to the repo, and confirm the name maps to a real project.

## Installing

Once you decide to install a package, your package manager becomes the next line of defense. [pnpm 11](https://pnpm.io/supply-chain-security) includes defaults that `npm` does not, so this section focuses on `pnpm`.

- [`minimumReleaseAge`](https://pnpm.io/settings#minimumreleaseage) is the biggest single setting. pnpm 11 defaults to 1440 minutes, refusing any version published in the last day.

<div className="markdown-alert,markdown-alert-tip" dir="auto">
<svg ariaHidden="true" className="octicon" height="16" viewBox="0 0 16 16" width="16">
<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z">



</path>
</svg>

TIP

npm has its own [`min-release-age`](https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age) setting for the same job, added in npm 11.10.0.

</div>

- [`strictDepBuilds`](https://pnpm.io/settings#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`](https://pnpm.io/package_json#pnpmonlybuiltdependencies). Keep the lockfile committed, and never run CI with `--no-frozen-lockfile`.

<div className="markdown-alert,markdown-alert-tip" dir="auto">
<svg ariaHidden="true" className="octicon" height="16" viewBox="0 0 16 16" width="16">
<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z">



</path>
</svg>

TIP

Npm does not have this, but you can set `--ignore-scripts` to disable all scripts.

</div>

In pnpm 11 these settings live in `pnpm-workspace.yaml`:

```yaml
# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
onlyBuiltDependencies:
  - esbuild
```

On npm, put the cooldown in `.npmrc`:

```ini
# .npmrc
min-package-age=72
ignore-scripts=true
```

## Publishing

If you publish to `npm`, you also need to protect your own releases from being hijacked.

- The biggest single change is dropping `NPM_TOKEN` and switching to [npm OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers).
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](https://nx.dev)'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](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) (`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](https://github.com/kubb-labs/kubb/blob/eaef40b9aa6e5e9497811de1d5dd69df34179a1f/.github/workflows/release.yml):

```yaml
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](https://pnpm.io/cli/publish) breaks that path in two.

<div className="doc-viz" dataAnimate="true">

Staged publishing splits the path in two

<div className="stage">
<div className="stage-lane">
<div className="stage-lane__label">

Without staging

</div>

<div className="dv-flow">
<div className="dv-node" style="--i:0">

CI runner

</div>

<div className="dv-arrow" style="--i:1">



</div>

<div className="dv-node,dv-node--danger" style="--i:2">

latest tag

</div>

<div className="dv-arrow" style="--i:3">



</div>

<div className="dv-node,dv-node--danger" style="--i:4">

every install

</div>
</div>
</div>

<div className="stage-lane">
<div className="stage-lane__label">

Staged publishing

</div>

<div className="dv-flow">
<div className="dv-node" style="--i:0">

CI runner

</div>

<div className="dv-arrow" style="--i:1">



</div>

<div className="dv-node" style="--i:2">

staged version

</div>

<div className="dv-arrow" style="--i:3">



</div>

<div className="stage-gate" style="--i:4">

2FA approval

</div>

<div className="dv-arrow" style="--i:5">



</div>

<div className="dv-node,dv-node--safe" style="--i:6">

latest tag

</div>
</div>
</div>
</div>

A stolen token publishes straight to latest without staging. With staging the release parks until a maintainer promotes it behind a 2FA prompt that never reaches the runner.

</div>

1. First, CI uploads. [`pnpm stage publish`](https://pnpm.io/cli/publish) signs the release and pushes it to [npm](https://www.npmjs.com) as a staged version that no one can install yet.
2. Second, a human promotes it. A maintainer reviews the staged version and moves it to `latest`, and [npm requires 2FA](https://docs.npmjs.com/configuring-two-factor-authentication) 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: 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 via staged publishing before they hit `latest`.
- Turn on [two-factor auth with a hardware key](https://docs.npmjs.com/configuring-two-factor-authentication) on your npm account.

None of this stops a determined attacker forever. Layering these controls means one failure does not have to end in a poisoned release.

## Sitemap

See the full [sitemap](https://stijnvanhulle.be/sitemap.md) for all pages.
