npm Supply Chain Attacks: How to Protect Your Codebase in 2026
npm supply chain attacks are now relentless. Here's the practical, senior-engineer playbook to harden your install, CI, and build pipeline in 2026.
On this page
- The attack patterns you're actually facing
- Lock it down: deterministic installs
- Kill lifecycle scripts by default
- Pin and override transitive dependencies
- Verify provenance with Sigstore
- Lock down credentials and CI permissions
- Slow down your updates on purpose
- Where npm audit stops being enough
- Reduce trust at the edges: vendoring, SBOMs, isolation
- Do these five things today
If you run npm install in CI and you haven't thought hard about what actually ends up executing on your build server, 2026 has your attention. Because the attacker certainly has.
I've shipped JavaScript for the better part of two decades, and the threat model has flipped. The dangerous code is no longer the code you wrote — it's the 1,400 transitive dependencies you never read, installed by a maintainer whose npm account got phished last Tuesday. The build server is the new soft target, and most teams are still treating npm install like it's 2015.
The attack patterns you're actually facing
These aren't hypotheticals. The last two years gave us a clinic on how this goes wrong:
- Typosquatting. A package named
reqeustsorlodahssits one keystroke away from the real thing, waiting for a fat-fingered install or a hallucinated import from an LLM. - Compromised maintainer accounts. A popular, trusted package gets a malicious patch release pushed by an attacker who phished or session-hijacked the maintainer. You didn't add a new dependency — a
^range silently pulled in the poisoned version. - Malicious
postinstallscripts. The payload doesn't even need to be imported. A lifecycle script runs the instant you install, scraping~/.aws,process.env, and your CI secrets before your build even starts. - Self-replicating worms. The 2025 wave showed packages whose payload used a leaked npm token to publish trojanized versions of other packages the victim maintained — turning one compromise into dozens. This is the part that should keep you up at night: it scales without human effort.
The common thread: the install step is code execution by an untrusted party. Treat it that way.
Lock it down: deterministic installs
Step one is making your installs reproducible. npm install mutates your lockfile and happily resolves new versions. npm ci does not — it installs exactly what the lockfile pins, and fails loudly if package.json and package-lock.json disagree.
# In CI, never this:
npm install
# Always this — exact, lockfile-driven, no surprise resolutions:
npm ciCommit your lockfile. Review changes to it in PRs like you'd review any other code, because a 4,000-line lockfile diff is exactly where a malicious version bump hides.
Kill lifecycle scripts by default
postinstall is the single most abused vector, and you almost never need it. Disable scripts globally and re-enable only the handful of packages that genuinely require a build step.
# .npmrc — checked into the repo
ignore-scripts=true
audit-level=high
fund=falseYes, this breaks packages that compile native bindings (sharp, esbuild, node-sap-*). That's a feature. Now you have an explicit allowlist instead of a blanket trust policy. In CI, install with scripts off, then rebuild only what you've vetted:
# Hardened install: nothing executes on install...
npm ci --ignore-scripts
# ...then explicitly rebuild ONLY packages you've audited and approved
npm rebuild esbuild sharpIf a brand-new package suddenly needs a postinstall to function, that's a signal worth investigating before you add it to the allowlist.
Pin and override transitive dependencies
Direct dependencies are easy to reason about. Transitive ones are where you get burned. The overrides field lets you force a specific resolved version deep in the tree — to patch a known-bad release or to pin a dependency that keeps drifting.
{
"overrides": {
"minimist": "1.2.8",
"color-name": "1.1.4",
"@babel/traverse": {
"ms": "2.1.3"
}
}
}I keep overrides for anything that's been hit historically. It's a small, readable surface that says "I have decided what version runs here," which is the whole point.
Verify provenance with Sigstore
Modern npm supports build provenance backed by Sigstore. A package published with provenance carries a cryptographically verifiable statement of which repo and which CI workflow built it. You can publish your own packages this way, and increasingly you can require it of dependencies via tooling.
# Publishing with provenance from CI
npm publish --provenance --access public
# Auditing what's signed in your tree
npm audit signaturesProvenance won't stop a maintainer from going rogue, but it makes "this artifact came from the source repo I think it did" a checkable fact instead of blind faith.
Lock down credentials and CI permissions
The 2025 worms spread on leaked tokens. So the goal is to make sure there's nothing valuable to leak.
- Use granular access tokens scoped to a single package or org, never legacy classic tokens with full publish rights.
- Enforce 2FA on every account with publish access. Non-negotiable.
- Set tokens to expire. A token that lives forever is a token that leaks eventually.
For CI itself, stop storing long-lived npm and cloud credentials as repo secrets. Use OIDC to mint short-lived, workflow-scoped tokens at runtime, and grant the workflow the absolute minimum permissions it needs:
name: Publish
on:
release:
types: [published]
permissions:
contents: read # default to read-only everything
id-token: write # OIDC, for provenance + short-lived creds
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci --ignore-scripts
- run: npm run build
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}The permissions block is doing heavy lifting here. Default GitHub Actions tokens are far more powerful than your publish job needs. Drop everything to read and add back only id-token: write.
Slow down your updates on purpose
This is counterintuitive, so stay with me. The riskiest moment for a compromised package is the first 24-72 hours after a malicious release, before the registry pulls it and before security tooling flags it. Auto-merging Dependabot or Renovate PRs the instant they open puts you on the bleeding edge of every attack.
Configure a cooldown window so updates age before you adopt them:
{
"extends": ["config:recommended"],
"minimumReleaseAge": "5 days",
"internalChecksFilter": "strict"
}Five days of soak time means the community and the registry have a chance to catch a bad release before it reaches your main. You give up almost nothing and dodge an entire class of zero-day supply chain hits.
Where npm audit stops being enough
npm audit checks known CVEs. It tells you nothing about a brand-new malicious package, because there's no advisory yet. Tools like socket.dev analyze behavior — a package that suddenly adds network calls, filesystem access, or obfuscated code in a patch release gets flagged on the change itself, not on a CVE that may never be filed. Run both. audit for the known, behavioral analysis for the unknown.
Reduce trust at the edges: vendoring, SBOMs, isolation
For the highest-stakes projects, go further:
- Private registry / vendoring. Proxy npm through Verdaccio or Artifactory so you control exactly which versions enter your org, and nothing pulls from the public registry mid-build.
- SBOMs. Generate a Software Bill of Materials (
npm sbom --sbom-format cyclonedx) on every build. When the next big disclosure drops, "are we affected?" becomes a five-second query instead of a frantic afternoon. - Runtime isolation. Run installs and builds in an ephemeral, network-restricted container with no access to production secrets or cloud metadata endpoints. If a
postinstalldoes fire, it should hit a wall, not your secrets manager.
Do these five things today
You can't fix everything this afternoon. Do these, in order:
- Switch CI from
npm installtonpm ciand commit your lockfile. Deterministic installs, today. - Add
ignore-scripts=trueto.npmrcand rebuild only an explicit allowlist. This shuts down the most-abused vector immediately. - Enforce 2FA and swap classic tokens for granular, expiring ones. Then lock CI
permissionsto read-only plusid-token: write. - Add a cooldown window (
minimumReleaseAge) to Dependabot/Renovate so you stop auto-adopting hour-old releases. - Add behavioral scanning (socket.dev or equivalent) alongside
npm audit, because CVE feeds don't catch novel attacks.
None of this is exotic. It's the difference between an install step you trust because you've never thought about it, and one you trust because you've made it earn it. In 2026, only the second kind is defensible.