Skip to main content
npm Supply-Chain Hardening in 2026: Lifecycle Scripts, minimumReleaseAge, and the Postinstall We Stopped Trusting

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

August 30, 2026
Security Engineering
9 min read

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. At Devya we treat three controls as the baseline for every Node repository we ship: dependency lifecycle scripts off by default, a refusal to install versions younger than a few days, and no long-lived publish tokens anywhere in CI. None of them requires changing a line of application code.

Key takeaways

npm lifecycle scripts — preinstall, install, postinstall, and prepare — are shell commands declared inside a dependency's own package.json and executed by the package manager with the developer's permissions during npm install, before any application code runs.

pnpm 10 stopped running dependency lifecycle scripts by default, and any package that genuinely needs a build step must be listed under onlyBuiltDependencies. npm has no equivalent default, so we set ignore-scripts=true in .npmrc and allow-list explicitly.

minimumReleaseAge is a cooldown setting, available in pnpm, in Renovate, and as Dependabot's cooldown configuration, that refuses any package version published less than N minutes ago. Compromised versions are typically detected and pulled within hours to a few days, so the cooldown absorbs exactly the window in which a team 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, because 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 when a developer types npm install?

npm install executes arbitrary code from the dependency tree before any of the team's 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, inheriting the shell environment, the ~/.npmrc file, SSH keys, and whatever cloud credentials sit on disk. There is no sandbox around it by default.

The detail most teams under-weight is that this applies to the entire transitive tree, not only the dependencies they chose. Five direct dependencies routinely resolve to several hundred packages, and any one of them can declare a postinstall script that nobody on the team has read.

The first practical step is measuring how many packages in the tree claim that right. npm ships a dependency selector for this: running npm query with the selector for packages that declare a postinstall attribute lists them directly. On pnpm 10 the equivalent is the pnpm approve-builds command, which lists the packages whose build scripts were blocked and asks which to permit. The first time we ran either command against a mature repository, the list was shorter than feared and less legitimate than hoped: a handful of native modules that genuinely compile, plus several pure-JavaScript packages running analytics pings.

How do we 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 small number of packages that genuinely need a build step.

The honest cost is that npm's ignore-scripts setting is not selective: it also disables the project's own scripts, so a repository relying on prepare to install Husky hooks will quietly stop doing so. We handle that by moving setup work into an explicit npm run setup step that both the README and the CI job invoke, and by running npm rebuild for native modules such as sharp and better-sqlite3 that need compilation.

pnpm's version of the same control is narrower and better, because the allow-list is per package rather than all-or-nothing: adding an onlyBuiltDependencies array under the pnpm key in package.json permits exactly the packages named there. Our heuristic for what belongs on that list: native addons and binary downloaders such as esbuild and playwright have a real reason to run at install time, while a pure-JavaScript utility requesting a postinstall does not, and that request is itself the signal worth investigating.

What is minimumReleaseAge, and why does a 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 exposes it as a first-class setting read from pnpm-workspace.yaml, Renovate has supported it as a config option for years, and Dependabot offers the same idea as a cooldown block in dependabot.yml. We run a three-day cooldown with an exclusion pattern for our own scoped packages, which publish and deploy the same day.

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 is then unpublished or deprecated. The cooldown does not make a team smarter than the attacker; it makes them later than the people who find the attack, which produces the same outcome for a fraction of the effort.

The trade-off is real and worth stating plainly: a team running a cooldown is deliberately three days behind on everything, security patches included. We keep a documented override, a one-line configuration exclusion plus a pull request explaining why, so consciously pulling a same-day CVE fix remains possible and remains visible in review.

Does npm provenance prove a package is safe?

No. npm provenance is a signed attestation, generated by npm publish with the --provenance flag inside a supported CI provider, that links a published tarball to the source repository, commit, and workflow that built it. It proves origin, not intent. The npm audit signatures command verifies registry signatures and attestations across an installed tree.

What provenance buys concretely: a stolen laptop token can no longer publish a tarball that claims to come from a given repository and carries a valid attestation, and an artifact can be diffed 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 issued by the CI run instead of reading a long-lived secret. That matters specifically because 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 the compromised 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 that one install will eventually execute hostile code, and make that install a boring target. Four changes do most of the work.

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

Pin GitHub Actions to a full forty-character commit SHA rather than a tag. Git tags are mutable, and the 2025 tj-actions/changed-files compromise worked by repointing existing tags at a malicious commit, so every workflow pinned to a major-version tag picked it up automatically.

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

Commit the lockfile and review its diff. The integrity hash pins the exact bytes, which makes the lockfile diff the one place where a surprise transitive bump is genuinely visible to a reviewer.

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

Disabling lifecycle scripts through ignore-scripts or onlyBuiltDependencies stops install-time code execution from any package in the tree, at the cost of native modules breaking until they are allow-listed.

A minimumReleaseAge cooldown stops freshly published compromised versions, which is the dominant attack shape, at the cost of running several days behind on all updates including security patches.

Using npm ci with a committed lockfile stops unreviewed transitive version drift between review and deploy, and costs effectively nothing.

Pinning GitHub Actions to a commit SHA stops mutable-tag hijacks of the pipeline, at the cost of bump churn that Renovate can automate.

Trusted publishing over OIDC stops token theft and therefore worm-style self-replication, at the cost of one-time setup and a requirement that publishing happen from CI.

Provenance combined with npm audit signatures stops tarballs that were not built from the source they claim, but does not stop malicious source, and ecosystem coverage remains partial.

If we could ship only one of these to a team tomorrow, it would be the cooldown, because it requires no allow-list maintenance and no change in developer behaviour. Disabling lifecycle scripts is strictly stronger but carries a first-week cost while the team discovers which packages actually needed them.

FAQ

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

A: It can, because npm's ignore-scripts setting also disables the project's own lifecycle scripts such as prepare, which is how Husky installs git hooks. Move that work into an explicit npm run setup step and run npm rebuild 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 the pnpm approve-builds command shows which packages were blocked so they can be permitted 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 a team would install it. The npm audit signatures command is a different check that verifies registry signatures and provenance attestations, and the two are complementary rather than interchangeable.

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

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