Supply-Chain Security: Trusting Your Dependencies

Run npm install and you invite a few hundred strangers into your build. Each dependency is code — real code that executes on your laptop, in CI, and in production, usually with your environment variables and your registry token sitting right there in the process. The average front-end project pulls in over a thousand packages once you count everything transitively. You did not read that code. Almost nobody does.

That was always a quiet risk. In the last two years it stopped being quiet. This article is about the threat as it actually looks in 2026, and — more importantly — the small set of concrete habits that shrink it. None of this requires a security team. Most of it is three config lines and a way of thinking about what a dependency can reach.

The threat, stated plainly

Some numbers, because the scale changes how you should feel about it. Sonatype’s 2026 supply-chain report counted more than 454,000 new malicious open-source packages in 2025 alone, pushing the cumulative total of blocked malware past 1.2 million — a 75% jump year over year. Over 99% of that malware targeted npm specifically, not PyPI or the others. npm is where the JavaScript is, so npm is where the attackers are.

The turning point was Shai-Hulud, first seen in September 2025: the first npm malware that replicated on its own. Once it infected a developer or CI machine, it harvested every token it could find — npm, GitHub, cloud credentials — and used those tokens to publish poisoned versions of other packages the victim controlled, which infected the next victim, and so on. A 2026 variant tracked as “Mini Shai-Hulud” compromised 170+ packages across 400+ malicious versions in one campaign, hitting projects as reputable as TanStack and Mistral. One group pushed 300+ malicious versions across 323 packages in a single 22-minute automated burst.

Four ways in

Attackers don’t have infinite creativity. Nearly every incident is a variation on four moves. The useful thing about knowing them is that each one has a specific, boring defense — and the defenses stack.

your machine+ CI + prodtokens, env, secretstyposquatexpresss, reqwestdependency confusioninternal name, public feedinstall scriptpostinstall runs on icompromised maintainerreal pkg, poisoned verread the name+ cooldownscope namespin registryignore-scripts(npm v12 default)cooldown +provenance
The four common attack paths into your build, and the single defense that closes each one. None of the defenses is exotic; together they cover the paths that matter.

Typosquatting

You meant to install cross-env. You typed crossenv, or cross-env-js, or a version with a doubled letter. The attacker registered that near-miss name months ago and has been waiting. The tell is almost always in the string itself: a swapped character, an extra s, a -js suffix, hyphenation that doesn’t match the real package. Copy-paste from the official docs instead of typing from memory, and read the name character by character before you hit enter.

Dependency confusion

Your company has an internal package, @acme/auth-utils, that lives on a private registry. An attacker learns that name (it leaks in error messages, bug reports, old commits) and publishes a package with the same name to the public npm registry, at a higher version number. If your install ever consults the public registry for that name, npm may pick the higher version — the attacker’s. Defense: keep internal packages scoped and configure that scope to resolve only from your private registry.

Malicious install scripts

This is the one that made Shai-Hulud fast. A package’s postinstall (or preinstall) script runs automatically the moment it lands on disk — before you’ve imported a single line, before any of your code runs. That script has your shell, your environment, your tokens. It’s the single most abused vector in the ecosystem, which is exactly why npm is changing the default (more below).

Compromised maintainer accounts

The scariest one, because there’s no misspelling to catch. The package is the real axios, or the real chalk — the one you actually wanted. But the maintainer’s npm account got phished, and a poisoned version shipped under the legitimate name. Version pinning alone doesn’t save you here; the fix is to never take a brand-new release immediately, plus verify provenance so you know the release came from the project’s CI and not someone’s stolen laptop.

Spot the typosquat

Before the defenses, a quick calibration. Typosquats are caught by reading, and reading is a skill you can practice. Each card below shows a real install command a teammate might paste into Slack. Decide safe or malicious, then reveal the tell.

interactiveSafe or squat?

Lockfiles: the reproducibility floor

Everything downstream assumes one thing: that the packages you audited are the exact packages that get installed. A lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock) is what makes that true. It pins every dependency and every transitive dependency to an exact version plus an integrity hash. If a published tarball’s bytes ever change, the hash won’t match and the install fails loudly.

Two rules make lockfiles actually protective:

Commit the lockfile. It belongs in git, always. A lockfile you don’t commit is a lockfile that doesn’t exist.

Install from it, strictly, in CI. npm install will happily update the lockfile to satisfy your ranges. In CI that’s exactly wrong — you want the install to fail if reality doesn’t match the committed file, not silently drift.

npm ci                 # npm: installs strictly from the lockfile, errors on mismatch
pnpm install --frozen-lockfile
yarn install --immutable
bun install --frozen-lockfile

Cooldown: let the internet find the malware first

Here’s the leverage move, and it’s newer than most people realize. Since compromised releases are almost always caught and yanked within hours, you don’t need to detect malware yourself — you just need to not be first. A cooldown (also called minimum release age) tells your package manager to ignore any version published less than N days ago. New releases simply aren’t visible to your installs until they’ve aged.

npm shipped this as min-release-age in CLI 11.10.0 (February 2026), measured in days:

# .npmrc — ignore anything published in the last 3 days
min-release-age=3

The other managers have the same idea under different names:

# pnpm: pnpm-workspace.yaml or .npmrc — minutes
minimumReleaseAge: 4320   # 3 days

Renovate and Dependabot both support a cooldown / minimumReleaseAge on the PRs they open, so your automated upgrades wait out the danger window too. pnpm 11 turned a cooldown on by default at 1440 minutes (one day), and npm v12 is expected to follow.

chalk 5.9.9published 20 min agono provenancechalk 5.9.0published 40 days agoprovenance verifiedinstall gatelockfile hash matchcooldown (3 days)provenanceREJECTEDtoo new + unverifiedINSTALLEDaged + hash + provenance ok
An install gate. A brand-new, unverified version fails the cooldown and provenance checks and is held back; an aged version with matching lockfile hash and verified provenance passes through.

Install scripts: the default is finally changing

For years the honest advice was: install with scripts disabled and re-enable per package.

npm install --ignore-scripts        # skip pre/post/install scripts globally

The catch was that a handful of legitimate packages (native addons, some build tools) genuinely need their install script, so blanket-disabling broke real things and most people didn’t bother. That era is ending. npm v12, shipping July 2026, disables dependency install scripts by default. Alongside it, --allow-git and --allow-remote default to none, so git and arbitrary-URL dependencies won’t resolve unless you opt in. These three defaults close the vector that made self-replicating worms viable.

You don’t have to wait for the breaking release to find out what it affects. Everything is already available in npm 11.16.0, so you can surface the exact packages v12 will block, today:

npm install                          # on 11.16+, warns about scripts that would be blocked
npm approve-scripts                   # review which packages want to run scripts
npm approve-scripts --all             # allow all currently-pending ones after review

The right move before July: upgrade to 11.16, run an install, look at the list of packages requesting scripts, and decide each one deliberately. Most projects find the list is short and boring — and every entry you don’t recognize is worth a second look.

npm audit, and where it stops

npm audit cross-checks your dependency tree against a database of known, disclosed vulnerabilities and tells you what’s affected and what version fixes it.

npm audit                    # report known CVEs in the tree
npm audit --audit-level=high # fail CI only on high/critical
npm audit fix                # bump to patched versions where the range allows

It’s worth running. But be clear-eyed about what it is and isn’t:

  • It’s a rear-view mirror. It only knows about vulnerabilities already reported. A brand-new malicious package — the thing you’re most worried about — isn’t in the database yet. Audit would not have caught Shai-Hulud on day one.
  • It’s noisy. Transitive advisories you can’t act on, and dev-only issues that never ship to production, generate fatigue. Tuning --audit-level and separating dev from prod dependencies keeps it useful.
  • audit fix can lie about being harmless. With --force it will happily install major-version bumps that break you. Read what it wants to do.

Audit is a good hygiene check for known problems. It is not a defense against novel malware. That’s a different toolset.

Tokens: shrink the blast radius

Every incident above gets dramatically worse if the machine that gets popped is holding a powerful token. A classic, long-lived npm token that can publish all your packages and never expires is the jackpot — it’s exactly what the worm harvests and reuses. The fix is to make any single stolen credential nearly worthless.

classic tokenstolentokenall packagesnever expiresread + writegranular tokenstolentokenone packageexpires in 7 days2FA required
The same breach, two token designs. A classic all-scope token hands the attacker your whole namespace forever; a granular, single-package, seven-day token barely opens a door before it expires.

The practical rules, all of which npm now nudges you toward by default:

  • Prefer no token at all. Publishing from CI via trusted publishing (next section) uses short-lived OIDC identity instead of a stored secret. Nothing to steal.
  • When you must use a token, make it granular. npm’s granular access tokens let you scope to specific packages and specific permissions. New write-enabled granular tokens now default to a 7-day expiry, cap at 90 days, and require 2FA.
  • Turn on 2FA for your account and org. Phished passwords are how maintainer accounts fall.
  • Never let a token touch a .npmrc you commit. Inject it from the CI secret store at publish time and nowhere else.

Provenance and trusted publishing

Provenance answers the question version pinning can’t: did this exact tarball really come from this project’s CI, from this commit? When you publish through trusted publishing from GitHub Actions or GitLab CI, npm generates a signed provenance attestation automatically — a cryptographic link from the published bytes back to the source repo and the build that produced them. On the package’s npm page you’ll see a provenance badge; consumers and tools can verify it.

This directly defends the compromised-maintainer path. A poisoned version published from a stolen laptop can’t forge provenance from your real CI pipeline, so a missing or mismatched attestation is a signal you can act on.

If you publish anything, the modern setup is worth adopting (it’s covered in depth in Publishing a Package): configure a trusted publisher for your package, publish from CI, and let OIDC replace the long-lived token entirely. Classic automation tokens are on the way out for exactly this reason.

An SBOM turns panic into a query

A Software Bill of Materials is a machine-readable list of everything in your build — every package, version, and license, transitive dependencies included. Two standard formats dominate: CycloneDX (application-security focused, the usual pick for JS) and SPDX (license/procurement focused). npm generates either:

npm sbom --sbom-format cyclonedx > sbom.json
npm sbom --sbom-format spdx > sbom.spdx.json

Generate one on every release and store it as a build artifact. The payoff shows up on the worst day. When the next “malicious version of popular-thing between 4.3.1 and 4.3.5” advisory drops at 2am, the question is are we affected, and where? Without an SBOM that’s a frantic grep across every repo and every deployed build. With one, it’s a single query against a file you already have.

Advisory: malicious foo between 4.3.1 and 4.3.5 — are we affected?without SBOMgrep repo A?grep repo B?check prod?did we miss one?hours, no certaintywith SBOMquery stored SBOMs for [email protected]affected: checkout, billingnot affected: everything elseseconds, exact
The same incident with and without an SBOM. Without, you scramble across repos hoping you checked them all. With, one query over stored bills of materials returns the exact affected services.

An SBOM is also feedable to a scanner. OSV-Scanner (Google’s, backed by the OSV database) takes an SBOM or lockfile and reports known vulnerabilities with precise version matching:

osv-scanner --lockfile=package-lock.json
osv-scanner --sbom=sbom.json

How to vet a new dependency

Before you add something, spend two minutes. Not a security review — just a smell test that catches the obvious problems.

npm view chalk                 # downloads, maintainers, latest, repo
npm view chalk scripts         # does it run install-time scripts?
npm view chalk time.modified   # when was it last touched?

The tooling landscape

You don’t have to do all of this by hand. As of 2026 the useful players:

  • Socket — analyzes package behavior (does this version newly add network access, filesystem writes, install scripts?) rather than only known CVEs. Strong at catching novel malicious updates, which is exactly the gap npm audit leaves.
  • Snyk — established vulnerability scanning, fix PRs, and license checks across the dependency tree; broad ecosystem coverage.
  • OSV-Scanner — free, open, CLI-first vulnerability scanning off the OSV database; great in CI and for scanning an SBOM.
  • Dependabot / Renovate — automated dependency-update PRs. Both now support cooldowns, so you get upgrades and the aging delay. Renovate’s config is more flexible; Dependabot is built into GitHub.

Pick one behavioral tool (Socket-style) and one vulnerability scanner, wire them into CI, and let cooldown + lockfiles + script-blocking do the quiet work underneath.

Summary

  • Every dependency is code you run with your tokens and env in scope. In 2026 that’s an industrialized threat — 1.2M+ cumulative malicious packages, 99% on npm, self-replicating worms like Shai-Hulud — but a small set of defaults covers the paths that matter.
  • Four attack shapes, four defenses: typosquats (read the name), dependency confusion (scope + pin your registry), install scripts (block them), compromised maintainers (cooldown + provenance).
  • Commit the lockfile and install strictlynpm ci / --frozen-lockfile / --immutable — so the packages you vetted are the packages you get.
  • Adopt a cooldown. min-release-age (npm 11.10, Feb 2026) and its pnpm/yarn/Renovate equivalents let the ecosystem catch malware in the first hours so you never install it.
  • Install scripts are becoming opt-out by default in npm v12 (July 2026); you can already surface and approve them on npm 11.16 with npm approve-scripts.
  • npm audit catches known CVEs, not novel malware — run it, but pair it with behavioral tooling like Socket.
  • Shrink the blast radius: prefer trusted-publishing OIDC over stored tokens; when you need a token make it granular, short-lived, and 2FA-gated.
  • Verify provenance to know a release came from real CI, and generate an SBOM per release so “are we affected?” becomes one query instead of a 2am scramble.