Mert Özen Further With Every Line
Security Frontend Security — #08

Hardening the npm Supply Chain: Lifecycle Scripts, Release Cooldowns, Provenance and Trusted Publishing

Mert Özen Nov 8, 2026 10 min 4 views
Hardening the npm Supply Chain: Lifecycle Scripts, Release Cooldowns, Provenance and Trusted Publishing

Hardening the npm supply chain rests on three pillars: limiting install scripts with an allow list, letting new versions wait a few days, and removing publish tokens with trusted publishing. In this post we try npm 11 and 12's allowScripts policy, pnpm's allowBuilds and minimumReleaseAge, npm's min-release-age, npm audit signatures output and OIDC-based trusted publishing with real installs against a local registry.

Version note: The examples in this post were run with Angular 22.2, Node.js 24, npm 11.19 and 12.2, pnpm 12.18 and Verdaccio 7 as a local registry.

Guess First

A developer on the Bookstore Angular project runs npm install in the morning. The lock file is in place, npm audit is clean, nobody added a new package; one dependency simply moved to a newer patch version inside its range. Can this command run code on your machine as your user? If it can, which files can it read: the token in ~/.npmrc, the environment variables in your CI (Continuous Integration) environment, your SSH (Secure Shell) keys?

The answer is yes. npm runs a package's preinstall, install and postinstall scripts as part of the installation, and in npm 11 that default is still on. We covered npm audit, npm ci, the lock file, SRI (Subresource Integrity) and SBOM (Software Bill of Materials) in the OWASP (Open Worldwide Application Security Project) series post "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit and SRI"; we will not repeat them here. This post looks one step further: what can you do against a malicious version that has no known advisory yet because it was published yesterday?

Lessons from 2025

In September 2025 two incidents shook the assumptions of the npm ecosystem. On September 8 a maintainer's account was taken over through a fake support email, and versions with injected malicious code were published for 18 popular packages such as chalk, debug and ansi-styles. According to the AWS (Amazon Web Services) write-up, those versions were available for a couple of hours before the maintainer and the npm team removed them. A few days later GitHub was notified of a self-propagating worm called Shai-Hulud. The malicious code ran in a postinstall script and used any npm tokens it found to add itself to other packages of the same maintainer and publish new versions. According to GitHub's statement of September 22, 2025, more than 500 packages were removed from the registry.

The details of the attack technique do not matter for us; the three lessons do. First, installing is running code: a dependency with a script runs with your permissions at npm install time. Second, the most dangerous version is the newest one: in both incidents the malicious versions were noticed within hours, so whoever waited won. Third, long-lived tokens are fuel: what let the worm spread were the publish tokens sitting on developer machines and in CI. Every setting below maps to one of these three lessons.

Bringing Install Scripts Under Control

First let's see which dependencies of the Bookstore project run code during installation. The install-scripts command in npm 11.19 lists them directly:

npm install-scripts ls
4 packages have install scripts not yet covered by allowScripts:
  esbuild@0.28.2 (postinstall: node install.js)
  lmdb@3.5.6 (install: node-gyp-build-optional-packages)
  msgpackr-extract@3.0.7 (install: node-gyp-build-optional-packages)
  @parcel/watcher@2.6.0 (install: node scripts/build-from-source.js)

Run `npm install-scripts approve <pkg>` to allow, or `npm install-scripts deny <pkg>` to deny.

Four packages, all coming from the Angular CLI (Command-Line Interface) build chain, and all running a script to verify or compile a native binary. To watch the behaviour I published a small package called book-cover-tools to my own local registry (Verdaccio, 127.0.0.1:19470). Its script only writes a marker file into the project root:

{
  "name": "book-cover-tools",
  "version": "1.0.1",
  "main": "index.js",
  "scripts": {
    "postinstall": "node install.js"
  }
}
// Simulates a native build step; only writes a marker file into the project root.
const fs = require('node:fs');
const path = require('node:path');
const root = process.env.INIT_CWD ?? process.cwd();
fs.writeFileSync(path.join(root, 'postinstall-ran.txt'),
  `book-cover-tools postinstall ran in ${path.relative(root, process.cwd())}\n`);
console.log('book-cover-tools: postinstall executed');

npm 11: on by default, ignore-scripts and allowScripts

npm install book-cover-tools
cat postinstall-ran.txt
added 1 package, and audited 2 packages in 393ms

found 0 vulnerabilities
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts   book-cover-tools@1.0.1 (postinstall: node install.js)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
book-cover-tools postinstall ran in node_modules/book-cover-tools

npm 11.19 warns but still runs the script; the marker file was created. The bluntest brake is --ignore-scripts (or ignore-scripts=true in .npmrc): when the same command ran with that flag, postinstall-ran.txt was not created. I checked whether this breaks the Angular build; with ignore-scripts=true, npm ci followed by ng build completed without problems, because esbuild and the others already download the platform-specific binary as an optional dependency. The finer control is the allowScripts policy. With --strict-allow-scripts the warning becomes an error:

npm install book-cover-tools --strict-allow-scripts
npm error code ESTRICTALLOWSCRIPTS
npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts:
npm error   book-cover-tools@1.0.1 (postinstall: node install.js)
npm error Approve them with `npm install-scripts approve`, deny them with `npm install-scripts deny`, or bypass this check with `--dangerously-allow-all-scripts`.

npm 12: off by default

The breaking changes of npm 12, released on July 8, 2026, include this item: dependency lifecycle scripts are blocked unless the root package's allowScripts policy allows them. I repeated the same installation with npm 12.2.2 and then approved the package:

npm install book-cover-tools
npm install-scripts approve book-cover-tools
npm rebuild
added 1 package, and audited 2 packages in 496ms

found 0 vulnerabilities
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   book-cover-tools@1.0.1 (postinstall: node install.js)
...
Approved book-cover-tools:
  added book-cover-tools@1.0.1
rebuilt dependencies successfully

The approval is written to package.json together with the version: "allowScripts": {"book-cover-tools@1.0.1": true}. That pinning (allow-scripts-pin, default true) is the real defense: when I published version 1.0.2 of the package and ran npm update, npm 12 blocked the new version's script again with the "1 package had install scripts blocked" warning. A new version coming from a compromised maintainer account cannot run on yesterday's approval. In Bookstore I denied Angular's four packages with npm install-scripts deny --all and the build still worked; after a clean npm ci on macOS, fsevents joined the list as well, and I denied it too.

pnpm: allowBuilds and strictDepBuilds

pnpm took this road earlier. pnpm 11 (April 28, 2026) removed onlyBuiltDependencies, neverBuiltDependencies, ignoredBuiltDependencies and ignoreDepScripts, moved them into a single allowBuilds map and made strictDepBuilds default to true. With pnpm 12.18 in a project without settings:

pnpm add book-cover-tools
dependencies:
+ book-cover-tools 1.0.2

Error: ERR_PNPM_IGNORED_BUILDS

  × adding a new package
  ╰─▶ Ignored build scripts: book-cover-tools@1.0.2
  help: Run "pnpm approve-builds" to pick which dependencies should be allowed
        to run scripts.

The installation ended with exit code 1; the script did not run. The permission lives in pnpm-workspace.yaml, and once I added the package to allowBuilds the script ran:

allowBuilds:
  book-cover-tools: true
.../node_modules/book-cover-tools postinstall$ node install.js
.../node_modules/book-cover-tools postinstall: book-cover-tools: postinstall executed
.../node_modules/book-cover-tools postinstall: Done

Release Cooldown: Letting New Versions Wait

The second lesson: malicious versions get caught within hours. So not taking a freshly published version for a few days is cheap insurance. npm added this in 11.10.0 (February 11, 2026) as the min-release-age setting; the unit is days. Today is November 8, 2026; @angular/core 22.2.5 was published on November 6 and 22.2.4 on October 29:

# .npmrc
min-release-age=3
npm install "@angular/core@^22.2.0"
npm ls @angular/core
`-- @angular/core@22.2.4

Even though the range is ^22.2.0, npm skipped 22.2.5, which is not three days old, and picked 22.2.4. Here is what happens when you ask for the exact version:

npm install @angular/core@22.2.5
npm error code ETARGET
npm error notarget No matching version found for @angular/core@22.2.5 with a date before 11/5/2026, 8:34:39 AM.

In pnpm the equivalent is minimumReleaseAge and the unit is minutes. pnpm 11 made it 1440 minutes (1 day) by default, but there is a detail you need to know: the default value is non-strict. Since my local package had no version that was a day old, pnpm 12 did not stop the installation; it warned and added the exception itself:

Added 1 entry to minimumReleaseAgeExclude in pnpm-workspace.yaml (set minimumReleaseAgeStrict to true to gate these updates with a prompt):
  book-cover-tools@1.0.2

If you write the value yourself, minimumReleaseAgeStrict becomes true automatically and pnpm refuses the installation:

minimumReleaseAge: 4320
Error: ERR_PNPM_NO_MATURE_MATCHING_VERSION

  × adding a new package
  ╰─▶ 1 version does not meet the minimumReleaseAge constraint:
        book-cover-tools@1.0.2 was published at 2026-11-08T05:25:09.529Z,
      within the minimumReleaseAge cutoff (2026-11-05T05:25:36.450Z)

With the same setting, adding @angular/core@^22.2.0 made pnpm pick 22.2.4 as well.

Renovate and Dependabot

If a bot opens your updates, define the waiting period there too; otherwise the bot opens a new PR (pull request) every day that goes red in CI. According to the Dependabot docs, version updates already wait 3 days by default; cooldown makes this configurable, and it does not apply to security updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 3
      semver-major-days: 14
      exclude:
        - "@bookstore/*"

In Renovate the setting is called minimumReleaseAge; its docs recommend 3 days for npm packages, citing the 72-hour unpublish window, and state that security updates bypass this check:

{
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "matchDatasources": ["npm"],
      "minimumReleaseAge": "3 days"
    }
  ]
}

I wrote these two files from the documentation; I did not run the bots against a repository.

Verifying Provenance and Signatures

npm audit signatures
audited 292 packages in 3s

292 packages have verified registry signatures

140 packages have verified attestations
(use --json --include-attestations to view attestation details)

Two separate things are verified. A registry signature shows that the tarball came from the npm registry unchanged. An attestation (provenance) documents which repository and which CI workflow built the package, in SLSA (Supply-chain Levels for Software Artifacts) format; for vite the JSON (JavaScript Object Notation) output shows "predicateType": "https://slsa.dev/provenance/v1". What stands out is that only 140 of the 292 packages have attestations: @angular/*, rxjs, typescript and tslib are published with registry signatures but without provenance. That does not mean those packages are unsafe; it shows that provenance is not universal yet. pnpm's trustPolicy: no-downgrade setting looks exactly at this gap: if a new version of a package that was previously published with provenance arrives without it, pnpm stops the installation. A manual publish from a compromised account usually looks exactly like that.

Your Own Package: Trusted Publishing and Tokens

The third lesson is about tokens, and this is where npm changed the most. GitHub disabled classic token creation on November 5, 2025 and revoked all existing classic tokens on December 9, 2025. Now npm login issues a two-hour session token, and granular tokens with write access live for at most 90 days. If the Bookstore team publishes internal packages to npm, the right path is trusted publishing: CI gets a short-lived identity through OIDC (OpenID Connect) and there is no token left to store. We saw the OIDC mechanics in the "Deploying with GitHub Actions 1: Azure App Service with OIDC and Secretless Cloud Access" post. According to the npm docs, you need at least npm CLI 11.5.1 and Node 22.14.0:

# .github/workflows/publish.yml
name: Publish Package
on:
  push:
    tags:
      - 'v*'
permissions:
  id-token: write
  contents: read
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm test
      - run: npm publish

On the package side you set up the relationship in the package settings on npmjs.com, or with the npm trust github <package> --file publish.yml --repository <owner/repo> --allow-publish command that arrived in npm 11.10. For a public repository and a public package, provenance is generated automatically and the --provenance flag is not needed. Do not skip the last step: tick "Require two-factor authentication and disallow tokens" in the package's "Publishing access" settings, otherwise an old token can still publish. I did not run this workflow with a real package; the file follows the example in the npm docs.

In Production

The deliberate exception to the cooldown is a security patch. If a critical advisory was fixed in 22.2.5, waiting three days is wrong. Take the patch explicitly and traceably:

npm install @angular/core@22.2.5 --min-release-age-exclude=@angular/core

This command installed 22.2.5. min-release-age-exclude arrived in npm 11.17; the pnpm equivalent is minimumReleaseAgeExclude. Write the exception into the PR description with the advisory number and remove it when you are done. A permanent exception list slowly makes the cooldown meaningless.

Enforce the policies in CI as well, otherwise you have only protected the developer machine. The script approval list can differ per platform; fsevents showed up on macOS and will not show up on a Linux runner. Keep approvals in package.json so they go through code review; if you see dangerously-allow-all-scripts or dangerouslyAllowAllBuilds in a pipeline file, ask why. Pinning actions to a commit SHA (Secure Hash Algorithm) and hardening install steps and tokens across the whole pipeline are the topic of the "GitHub Actions Security: SHA Pinning, Lockfiles, pull_request_target and Preventing Script Injection" post and of the upcoming DevSecOps series.

Common Pitfalls

First, believing that ignore-scripts solves everything. Code that does not run at install time still runs when the application or the build runs; script control shrinks the attack surface, it does not inspect package contents. That is why cooldowns and provenance are needed as separate layers.

Second, relying on pnpm's default one-day waiting period. As we saw, the default is non-strict and can quietly write the exception to a file. Write the value explicitly into pnpm-workspace.yaml so that strict mode kicks in. pnpm 12 now also reports misspelled setting names; in older versions a typo in minimumReleaseAge disabled the policy without any warning.

Third, approving scripts without a version. With allow-scripts-pin=false an approval means "every version", and the next malicious version counts as approved too.

Fourth, treating missing provenance as an alarm and present provenance as a guarantee. Provenance proves where the build came from; it does not prove that the code in that repository is harmless.

A Small Experiment

★☆☆ Run npm install-scripts ls in your own Angular project, deny the listed packages with deny, and confirm that ng build and ng test still pass.

★★☆ Put min-release-age=7 in .npmrc, predict from the npm view @angular/core time --json output which version should be picked, then confirm your prediction with npm install.

★★★ Set up a local Verdaccio and publish two versions of a package with a script. With npm 12, approve the first one, update to the second, and explain by looking at the allowScripts key in package.json why the approval did not carry over.

In the next and final post of the series we look back: what changed in frontend security from Angular 11 to 22 and in browsers, and how to find which setting is missing in which version when you audit an old Angular project.