DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

npm Supply-Chain Hardening in 2026: Lifecycle Scripts, minimumReleaseAge, and the Postinstall I Stopped Trusting

Headline: The npm attacks that actually landed over the past two years executed at install time, through lifecycle scripts, within hours of the malicious version being published. Turning dependency lifecycle scripts off by default, refusing versions younger than a few days, and removing long-lived publish tokens from CI blocks most of that path without changing a line of application code.

Key takeaways

  • npm lifecycle scripts (preinstall, install, postinstall, prepare) are shell commands declared inside a dependency's own package.json and executed by your package manager, with your user's permissions, during npm install — before any of your code runs.
  • pnpm 10 stopped running dependency lifecycle scripts by default; a package that genuinely needs a build step must be listed in onlyBuiltDependencies. npm has no such default, so I set ignore-scripts=true in .npmrc and allow-list explicitly.
  • minimumReleaseAge is a cooldown setting — in pnpm, in Renovate, and as Dependabot's cooldown config — that refuses any version published less than N minutes ago. Compromised versions are usually detected and pulled within hours to a few days, so a cooldown absorbs exactly the window in which you would have installed one.
  • npm provenance attestations prove which repository, commit, and CI workflow produced a tarball. They do not prove the code is benign: a compromised workflow emits perfectly valid provenance.
  • Trusted publishing over OIDC removes the long-lived npm token from CI. That token is the credential the self-replicating npm worm of 2025 harvested from postinstall scripts in order to publish itself into the maintainer's other packages.

What actually runs on my machine when I type npm install?

npm install executes arbitrary code from your dependency tree before any of your own code runs. A lifecycle script is a command declared in a package's scripts field that the package manager invokes automatically at install time — it inherits your shell environment, your ~/.npmrc, your SSH keys, and whatever cloud credentials sit on disk. There is no sandbox around it by default.

The part I under-weighted for years is that this applies to the whole transitive tree, not just the dependencies I chose. Five direct dependencies routinely resolve to several hundred packages, and any one of them can declare a postinstall.

The first useful step is knowing how many packages in your tree claim that right:

# every package in the installed tree that declares an install-time script
npm query ":attr(scripts, [postinstall])" | jq -r '.[].name'
Enter fullscreen mode Exit fullscreen mode

On pnpm 10 the equivalent is pnpm approve-builds, which lists the packages whose build scripts were blocked and asks which ones to permit. The first time I ran either command on a mature repo, the list was shorter than I feared and less legitimate than I hoped — a handful of native modules that really do compile, and several pure-JavaScript packages running analytics pings.

How do I stop dependency lifecycle scripts from running?

Set ignore-scripts=true in the project's .npmrc for npm and Yarn, or stay on pnpm 10's default of not running dependency scripts, then allow-list the few packages that genuinely need a build step.

# .npmrc — checked into the repo, applies to every install here
ignore-scripts=true
Enter fullscreen mode Exit fullscreen mode

The honest cost: npm's ignore-scripts is not selective. It also disables your own package's scripts, so a repo relying on prepare to install Husky hooks will silently stop doing that. I move those into an explicit npm run setup step that the README and the CI job both call, and run npm rebuild sharp better-sqlite3 for native modules that need compilation.

pnpm's version is narrower and better, because the allow-list is per package rather than all-or-nothing:

{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "sharp", "better-sqlite3"]
  }
}
Enter fullscreen mode Exit fullscreen mode

The heuristic for that list: native addons and binary downloaders (esbuild, playwright) have a real reason to run at install. A pure-JavaScript utility asking for a postinstall does not, and that request is itself the signal.

What is minimumReleaseAge, and why does a release cooldown stop worm-style attacks?

minimumReleaseAge is a package-manager setting that refuses to install any version published more recently than a given number of minutes. pnpm reads it from pnpm-workspace.yaml, Renovate has supported it as a config option for years, and Dependabot exposes the same idea as a cooldown block in dependabot.yml.

# pnpm-workspace.yaml
minimumReleaseAge: 4320          # minutes; 3 days
minimumReleaseAgeExclude:
  - "@my-scope/*"                # our own packages publish and deploy same-day
Enter fullscreen mode Exit fullscreen mode
// renovate.json
{
  "minimumReleaseAge": "3 days",
  "internalChecksFilter": "strict"
}
Enter fullscreen mode Exit fullscreen mode

A cooldown is disproportionately effective because a malicious npm version has a short shelf life. Registry maintainers, scanners, and other developers find these within hours to a couple of days, and the version gets unpublished or deprecated. The cooldown does not make you smarter than the attacker; it makes you later than the people who find them, which is the same outcome for a fraction of the effort.

The trade-off is real: you are deliberately three days behind on everything, including genuine security patches. I keep a documented override — a one-line config exclusion plus a PR that says why — so consciously pulling a same-day CVE fix stays possible and stays visible.

Does npm provenance prove a package is safe?

No. npm provenance is a signed attestation, generated by npm publish --provenance running in a supported CI provider, that links a published tarball to the source repository, commit, and workflow that built it. It proves origin, not intent.

# verify registry signatures and attestations for the installed tree
npm audit signatures
Enter fullscreen mode Exit fullscreen mode

What provenance buys me: a stolen laptop token can no longer publish a tarball that claims to come from our repo and carries a valid attestation, and I can diff a published artifact against the commit it names. What it does not cover: a maintainer who merges a malicious commit, a compromised build workflow, or the several hundred transitive packages that publish no attestation at all.

The complementary control is trusted publishing, where npm exchanges a short-lived OIDC token from the CI run instead of reading a long-lived secret. Token theft is what turned the 2025 npm worm from an incident into a spreading one: its postinstall payload scraped credentials and used them to publish itself into whatever else that maintainer owned. A publish flow with no long-lived token in the environment has nothing to scrape.

What should CI change to reduce the blast radius of a bad install?

Assume one install will eventually execute hostile code, and make that install a boring target.

Use npm ci, never npm install, in CI. npm ci installs strictly from package-lock.json and fails when the lockfile and package.json disagree, removing silent transitive drift between the version a human reviewed and the version CI resolved.

Pin GitHub Actions to a full commit SHA, not a tag. Git tags are mutable, and the 2025 tj-actions/changed-files compromise worked by repointing existing tags at a malicious commit, so everyone pinned to @v4 picked it up automatically.

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Enter fullscreen mode Exit fullscreen mode

Keep secrets out of the job that installs dependencies. The install step needs no publish token, no cloud key, no deploy credential. Splitting install/build from publish/deploy into separate jobs, with permissions: contents: read on the first, means an install-time payload runs in a job holding nothing worth stealing.

Commit the lockfile and review its diff. The integrity hash pins bytes, so the lockfile diff is the one place a surprise transitive bump is actually visible to a reviewer.

Which hardening step buys the most safety per unit of pain?

Control What it stops What it costs
ignore-scripts / onlyBuiltDependencies Install-time code execution from any package in the tree Native modules break until allow-listed; npm's flag also disables your own scripts
minimumReleaseAge cooldown Freshly published compromised versions, the dominant attack shape You run N days behind on everything, security patches included
npm ci + committed lockfile Unreviewed transitive drift between review and deploy Effectively none
Actions pinned to commit SHA Mutable-tag hijack of your CI pipeline Bump churn; let Renovate update the SHAs
Trusted publishing (OIDC) Token theft, and therefore worm-style self-replication One-time setup; publishing must happen from CI
Provenance + npm audit signatures Tarballs not built from the source they claim Does not stop malicious source; ecosystem coverage is partial

If I could ship only one of these to a team tomorrow, it would be the cooldown: no allow-list maintenance, no developer behaviour change. Disabling lifecycle scripts is strictly stronger but has a first-week cost while you discover which packages actually needed them.

FAQ

Q: Will ignore-scripts=true break my project?

A: It can, because npm's ignore-scripts also disables your own package's lifecycle scripts such as prepare, which is how Husky installs git hooks. Move those into an explicit npm run setup step and run npm rebuild <package> for native modules that need compiling.

Q: Does pnpm block install scripts by default?

A: Yes. pnpm 10 does not run dependency lifecycle scripts unless the package is listed in onlyBuiltDependencies, and pnpm approve-builds shows which packages were blocked so you can permit them individually.

Q: How long should a release-age cooldown be?

A: Three to seven days covers the detection window for most publicly disclosed npm compromises. Keep a documented override path so an urgent security patch can still be pulled the same day.

Q: Isn't npm audit already covering this?

A: No. npm audit reports known CVEs from published advisories, and a malicious version has no advisory at the moment you would install it. npm audit signatures is a different check that verifies registry signatures and provenance attestations; the two are complementary.

Q: Does the lockfile's integrity hash protect me from a malicious package?

A: The integrity field is a subresource hash guaranteeing you received the same bytes as when the lockfile was written. It protects against tampering between the registry and you; it says nothing about whether those bytes were malicious when published.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The distinction that makes this worth reading past the headline is that npm audit and npm audit signatures check orthogonal things — a malicious fresh version has no advisory yet, and a valid signature says nothing about intent. Combined with pnpm 10 flipping the default on dependency lifecycle scripts, the "pure-JS utility asking for a postinstall is itself the signal" heuristic is now the one I'd want on a code review checklist.

One gap I'd like your take on: the cooldown. A three-day minimumReleaseAge is brilliant against worm-style publish windows and terrible against an emergency CVE fix on day zero. You mention a documented override path — in practice, who or what is allowed to pull the trigger on that override, and does the override itself get reviewed later or just logged? That's usually where these policies survive or rot.