Yesterday it was TanStack. 42 packages, 84 malicious versions, published to npm in a six-minute burst, live for about twenty minutes before a security researcher caught it. The attacker didn't steal anyone's npm token. They compromised TanStack's CI pipeline, poisoned a build cache across a fork-PR trust boundary, and extracted the GitHub Actions worker's OIDC token to publish directly. The payload ran on npm install, harvested every credential it could reach (AWS, GCP, Kubernetes, Vault, ~/.npmrc, GitHub tokens, SSH keys), exfiltrated it over an encrypted messenger network, and republished itself into other packages the victims maintained. The postmortem↗ is worth reading in full.
I spent an afternoon rolling out a set of npm security best practices across all of LocalCan's repositories (four backend/frontend services plus the Electron desktop app) to harden them against this class of attack. This post is that checklist: what I changed, why, and the order I'd do it in starting from scratch. None of it is exotic. Most of it is two lines of config.
Why npm supply chain attacks keep happening
A few months before TanStack it was the "Shai-Hulud" worm tearing through hundreds of packages. Before that it was the chalk/debug incident: a maintainer phished, a dozen of the most-downloaded packages on npm briefly shipping a crypto-clipper. The pattern is clear: these are getting more frequent, more automated, and faster. The gap between "malicious version goes live" and "it's in your node_modules" is now measured in minutes.
The reason it works so well is structural. A typical Node project has hundreds to thousands of transitive dependencies, each maintained by someone you've never met, any of which can run arbitrary code on your machine the moment you install it. You don't need a vulnerability; you just need one of those maintainers to get phished, or one CI pipeline somewhere to be misconfigured. So the defense isn't "audit every dependency" (you can't). It's to put friction at the few choke points where a compromise actually reaches you.
The mental model: three places you can intervene
Almost every "supply chain security" article fixates on one thing: scanning your dependencies for known vulnerabilities. That's fine, but it's the last line, not the first, and it does nothing against a zero-day malicious publish. It helps to think about three distinct moments:
- Resolution: when a version gets chosen and written into
package-lock.json. This happens onnpm install <pkg>,npm update, or a fresh install with no lockfile. This is where a brand-new malicious version slips in. - Installation: when packages are unpacked and their lifecycle scripts (
preinstall/install/postinstall) run. This is the detonation step. The TanStack payload, the Shai-Hulud worm, the chalk clipper: all of them ran as install scripts. - Execution environment: if something does run, what can it touch? Your CI runner's tokens, your cloud metadata endpoint, your Docker daemon, your SSH keys.
You want a control at each layer. Here's the npm security checklist we actually ran, roughly in order of bang-for-buck.
1. Commit your lockfile and use npm ci everywhere
This is the single highest-leverage thing, and most teams already do half of it.
npm ci vs npm install, in one line: npm ci installs exactly what's pinned in package-lock.json, byte-for-byte, verifying every package against its integrity (SRI) hash, and never re-resolves; npm install (and pnpm install, yarn install) will re-resolve and pull in newer versions whenever anything has drifted.
Read the TanStack postmortem again with that in mind: the people who got burned were running npm install / pnpm install / yarn install during the twenty-minute window. Anyone whose CI ran npm ci against a lockfile committed before the bad versions existed was untouched. They installed the versions they'd already reviewed.
So:
- Commit
package-lock.json. Always. - In CI and Dockerfiles, use
npm ci, nevernpm install. - Treat any change to
package-lock.jsonin a PR the same way you'd treat a change to production code, because it is one.
Our Dockerfiles already did this. If yours don't, fix it before you do anything else on this list.
2. Add an install cooldown with min-release-age
Recent npm (11.x) has a config option that is exactly the right shape for this threat: min-release-age. Set it to a number of days, and npm will refuse to resolve any version that was published more recently than that.
# .npmrc supply-chain cooldown: don't resolve deps published less than a day ago.
min-release-age=1
Under the hood npm just turns this into before = now - N days and filters the registry metadata accordingly. (min-release-age and the older --before flag are two ways of expressing the same thing: one relative, one absolute. You can't use both at once.)
Why this works: the worst incidents are caught fast (TanStack in ~20 minutes, chalk/debug within an hour or two), but the malicious version still has to land in your lockfile to hurt you. With a one-day cooldown, when a teammate runs npm update the morning after a compromise, npm simply won't pick the poisoned version. The community (and npm's security team) have a head start.
A few things to know:
- It does nothing to
npm ci.npm ciinstalls from the lockfile and doesn't re-resolve, so the cooldown is a no-op there, by design. The protection happens upstream, at the moment a version enters the lockfile, which is on developer machines and any CI step that runsnpm install/update. Commit the.npmrcso everyone gets it. - One day is a floor, not a ceiling. A stealthier attack (one that doesn't break tests, the way the TanStack one did) could go undetected for longer. Bump it to
3or7if you can live with not adopting brand-new releases for a few days. The cost is exactly that: you wait. - It needs npm 11.x. Older npm silently ignores the key. (One more reason to be on a current Node; see below.)
- pnpm has its own:
minimumReleaseAge(with aminimumReleaseAgeExcludeallow-list), in pnpm 10.16+. Yarn has no native equivalent yet; you'd reach for a third-party check. - Your update bots need their own cooldown too.
min-release-ageonly kicks in when you runnpm install/update. Dependabot and Renovate do their own resolution, so by default they'll happily open a PR that bumps you to a release published an hour ago, and if you auto-merge dependency PRs you've just ingested it. Configure a cooldown there as well: Dependabot'scooldown:block independabot.yml(default-days, plussemver-major-days/semver-minor-days/semver-patch-days), or Renovate'sminimumReleaseAge. And while you're at it, reconsider auto-merge on dependency PRs. - It is not a lockfile editor. If someone commits a malicious
package-lock.jsondirectly,min-release-agewon't save you; that's what code review and protected branches are for (item 5).
We added this to all five LocalCan repos. It's the cheapest meaningful control on the list.
3. Don't run install scripts in CI: --ignore-scripts
This is the one that actually stops the detonation. By default npm runs the preinstall / install / postinstall scripts of every package in the tree, direct and transitive. That's arbitrary code, executed on whatever machine ran the install. In CI, that machine has your registry token, your cloud credentials, sometimes your Docker socket.
The fix in a Dockerfile or CI install step:
COPY package*.json ./
# Don't execute dependency lifecycle scripts during install, neutralising the
# most common npm supply-chain detonation vector.
RUN npm ci --ignore-scripts
The important nuance: put --ignore-scripts on the npm ci command, not in .npmrc. A project-level ignore-scripts=true affects everyone, including local npm install, and that breaks the install scripts your dev tooling legitimately needs (Playwright downloading browsers, MSW copying its service worker, native modules building). Scoping it to the CI/Docker npm ci line means the controlled, reproducible build doesn't run scripts, while developers' machines still work normally.
What about packages that genuinely need an install script, like sharp for image processing, better-sqlite3, or anything with a prebuilt native binary? Allow-list them explicitly:
# ignore everything, then re-run only sharp's install script: a one-package allow-list
RUN npm ci --ignore-scripts && npm rebuild sharp
npm rebuild <pkg> re-runs the lifecycle scripts for just that package. You've gone from "every dependency can run code on my build server" to "only sharp can, and I chose that." Note that --ignore-scripts does not disable optionalDependencies: the platform-specific binary packages (@esbuild/linux-x64, @img/sharp-linux-x64, @rollup/rollup-*, etc.) still install, so most modern native deps work fine without their install script in the first place.
We added npm ci --ignore-scripts to the Dockerfiles of all four LocalCan web services, with the npm rebuild sharp allow-list on the Next.js marketing site, and verified every image still builds. Then we deliberately didn't add it to the Electron app; more on that next.
When --ignore-scripts doesn't fit: Electron apps and friends
Be honest about where this control breaks down. Some projects are built around install scripts:
- The
electronpackage's postinstall downloads the Electron binary. No script, no app. better-sqlite3and other native modules build (or fetch) a.nodeaddon at install time.@electron/rebuildrebuilds native modules against Electron's ABI.
For a project like that, a blanket --ignore-scripts just breaks npm install for everyone, and there's usually no clean place to scope an allow-list because the install happens on developer/release machines rather than in a controlled CI image. So for the LocalCan desktop app I left --ignore-scripts off entirely and leaned harder on the other layers: min-release-age, lockfile review, and watching the lockfile diff for new transitive deps that introduce install scripts. That's the realistic posture there, and it's worth saying so rather than pretending otherwise.
4. Lock down the build environment itself
The TanStack root cause wasn't a bad dependency; it was a misconfigured CI pipeline. So harden the pipeline:
- Pin base images by digest.
FROM node:24-alpineis a mutable tag; tomorrow it points at a different image.FROM node:24-alpine@sha256:…doesn't. The postmortem puts it bluntly: "floating refs … on third-party actions create standing supply-chain risk independent of this incident." Same goes forimage: docker:latestin a.gitlab-ci.yml: pin it. Renovate or Dependabot can manage the digests so you still get updates, just deliberately. - Don't expose secrets to untrusted code. The classic vector is a pipeline triggered by a pull request from a fork that runs in a privileged context (GitHub's
pull_request_target, or a GitLab pipeline with protected variables on a fork MR). If a build job has your registry token or deploy credentials, fork code must never run in it. Our pipelines only run the privileged build on trusted branches; keep it that way. - Minimize token scope and blast radius. A CI job token that can only do what that job needs. No long-lived cloud credentials sitting in the build job. If you can, restrict the runner's outbound network during
npm ci; the TanStack payload exfiltrated to external hosts, and egress filtering kills that. - Bump to a current Node/npm. We moved every LocalCan image to
node:24-alpine(npm 11.x). That's not just hygiene: npm 11 is what understandsmin-release-age, and newer toolchains get security fixes the old ones don't.
5. Add a tripwire and require review on dependency changes
- Run
npm audit --audit-level=high(orcritical) as a CI step so a known-bad advisory fails the build. It won't catch a zero-day, but it catches the long tail. - Put
package.json,package-lock.json, and.npmrcbehind CODEOWNERS / protected-branch review. A surprising lockfile diff in a PR should require a human to look at it.min-release-ageprotects against npm picking a bad version; review protects against someone handing you one.
6. Have a "treat the host as compromised" plan
If you ever realize you installed an affected version, the postmortem's guidance is the right instinct: assume the install host is compromised and rotate everything reachable from it: cloud keys, Kubernetes/Vault tokens, GitHub and npm tokens, SSH keys. Know now what that list is for your dev machines and your CI runners, so it's a checklist and not a 2 a.m. scramble.
What this does and doesn't buy you
Set expectations honestly:
- ✅ A cooldown shrinks the window an attacker has, usually past the point where these things get caught.
- ✅
--ignore-scriptsneutralizes the most common detonation mechanism for CI/Docker builds. - ✅ A committed lockfile +
npm cimeans your builds install the versions you reviewed, not whatever's newest. - ✅ Digest-pinned images and a locked-down pipeline close the door the TanStack attacker actually walked through.
- ❌ None of it stops a malicious commit that edits
package-lock.jsondirectly; that's code review. - ❌ None of it stops a compromise that stays hidden longer than your cooldown. Widen the cooldown, and don't assume you'll hear about it fast.
- ❌
--ignore-scriptsisn't a fit for projects that are built around install scripts (Electron, heavy native modules); those need the other layers and a closer eye on the lockfile.
The 30-minute version
If you do nothing else today:
- Commit your lockfile; switch CI/Docker to
npm ci. - Add
min-release-age=1to a committed.npmrc(and make sure you're on npm 11.x), and set a matching cooldown in Dependabot/Renovate. - Add
--ignore-scriptsto thenpm ciin your Dockerfile/CI; allow-list native deps withnpm rebuild <pkg>. - Digest-pin your base images.
- Add
npm audit --audit-level=highto CI and put dependency files behind review.
That's it. We did all of the above across LocalCan's repos in an afternoon, and the build pipelines came out the other side unchanged except for being a lot harder to weaponize. The next worm is coming (there's always a next worm), and most of these controls cost you nothing but the discipline to set them up before it does.
FAQ
Is npm install safe?
Most of the time, yes, but "most of the time" is exactly the problem with supply chain attacks. npm install re-resolves your dependency tree and will happily pull in a version published minutes ago, and it runs every package's install scripts. The mitigations above (a committed lockfile + npm ci in automation, min-release-age as a cooldown, --ignore-scripts in CI) are about closing that "most of the time" gap.
What's the difference between npm ci and npm install?
npm ci does a clean install from package-lock.json exactly as written: same versions, verified against their integrity hashes, no re-resolution, and it errors if the lockfile and package.json disagree. npm install will add/update/remove packages to satisfy package.json and rewrite the lockfile. Use npm install when you're intentionally changing dependencies; use npm ci everywhere else, especially CI and Docker builds; it's faster and reproducible, and it won't surprise you with a brand-new (possibly compromised) version.
Does min-release-age slow down my builds?
No. It only affects resolution (npm install <pkg>, npm update), not npm ci, so your CI/Docker builds, which install from the lockfile, are unaffected. The only "cost" is that you can't adopt a release until it's older than the cooldown, which is the point.
Can't I just run npm audit and call it a day?
npm audit checks your tree against a database of known advisories. A fresh malicious publish isn't in any database yet; that's the whole danger. Audit is a useful tripwire for the long tail of known-vulnerable packages, but it does nothing against a zero-day supply chain attack. You need the install-time controls for that.
LocalCan gives you .local domains and persistent public URLs for local development: a fast, native ngrok alternative↗. We build it the way we'd want our tools built: boring, durable, and not a liability. If a post like this is useful, the rest of the blog↗ is in the same spirit.
