All postsEngineering

Hardening npm installs and publishes against supply chain attacks

Lockfiles, install-script allowlists, release-age cooldowns and trusted publishing, tested on current npm and pnpm, with what each one does and doesn't stop.

Johnny Unar10 min read

01

two ways a dependency turns on you

Almost every npm incident of the last two years fits one of two shapes. In the first, someone gets publish rights to a package you already trust and ships a new version with a payload. In the second, that payload runs the moment the package lands on disk, because npm and pnpm will execute code from a dependency during install unless you tell them not to. Put the two together and a single npm install on a CI runner hands an attacker every secret that runner can read.

The recent cases show the variations. On 31 March 2026, axios 1.14.1 and 0.30.4 were published from the lead maintainer's account after his PC was compromised through a targeted social engineering campaign, according to the project's own post mortem. Both versions added a dependency called plain-crypto-js@4.2.1 whose postinstall script installed a remote access trojan on macOS, Windows and Linux, and they were live for about three hours. In September 2025 a phishing email posing as npm support took over the account behind chalk, debug and a batch of other very popular packages; the injected code hijacked crypto wallet calls in the browser, and the bad versions were pulled within hours. In June 2026 StepSecurity documented a wave it called Phantom Gyp, where 57 packages shipped a tiny binding.gyp instead of any package.json script. And in May 2026, 84 malicious @tanstack/* versions were published through TanStack's own CI with valid provenance, which is a story about GitHub Actions as much as npm.

This guide covers what you control as a consumer of packages (lockfiles, install scripts, cooldowns, CI layout) and, for teams that publish to npm, what you control on the publishing side. Everything below was tested on npm 11.20, npm 12.2 and pnpm 12.11 in a scratch project with throwaway packages.

02

lockfiles stop new versions, which is most of the battle

A committed lockfile installed with npm ci or pnpm install --frozen-lockfile resolves exactly the versions and integrity hashes that are already in the file. None of the attacks above changed bytes of an existing version; they all published new versions. So a pipeline that only ever installs from a reviewed lockfile kept pulling axios 1.14.0 straight through the three-hour window. You'll sometimes read that the chalk and debug hijack proved lockfiles useless because the malicious tarball's hash matched what the registry served. The hash did match, but only for people whose lockfile was updated to the new version while it was live. A lockfile that pointed at the previous version never asked for it.

The exposure lives in every place where resolution happens fresh: a project with no lockfile, npm install some-package on a laptop, npx and pnpm dlx, a Dependabot or Renovate pull request that regenerates the lockfile and gets merged quickly, and Docker builds that copy only package.json. That last one is common and easy to fix:

dockerfile
1COPY package.json package-lock.json ./
2RUN npm ci --ignore-scripts
3COPY . .

In CI, npm ci fails if package.json and the lockfile disagree, and pnpm defaults to a frozen lockfile when it detects CI. It's still worth failing the build when someone commits the wrong lockfile, for example a stray package-lock.json in a repo whose packageManager field says pnpm, because a lockfile nobody reviews is where a surprise dependency hides.

Lockfiles also tell you more than people use. npm's package-lock.json marks every package that has an install script with "hasInstallScript": true and records the exact resolved URL, so a dependency pull request can be checked mechanically. This script compares the lockfile on the base branch with the one in the pull request and fails when a new install script or a non-registry source appears:

js
1import { readFileSync } from 'node:fs'
2
3const [basePath, headPath] = process.argv.slice(2)
4const load = (p) => JSON.parse(readFileSync(p, 'utf8')).packages ?? {}
5const base = load(basePath)
6const head = load(headPath)
7
8const findings = []
9for (const [path, pkg] of Object.entries(head)) {
10 if (path === '') continue
11 const before = base[path]
12 const name = path.replace(/^.*node_modules\//, '')
13 const id = `${name}@${pkg.version ?? '?'}`
14 if (!before) findings.push(`new package ${id}`)
15 if (pkg.hasInstallScript && !before?.hasInstallScript) findings.push(`install script ${id}`)
16 if (pkg.resolved && !pkg.resolved.startsWith('https://registry.npmjs.org/')) {
17 findings.push(`non-registry source ${id} -> ${pkg.resolved}`)
18 }
19}
20
21console.log(findings.join('\n') || 'no new packages, install scripts or sources')
22process.exitCode = findings.some((f) => !f.startsWith('new package')) ? 1 : 0

Run it as git show origin/main:package-lock.json > base.json && node lock-risk.mjs base.json package-lock.json and post the output on the pull request. A reviewer will read twenty lines about new packages and new install scripts. Nobody reads three thousand lines of lockfile churn.

03

install scripts should be off unless you approved them

The second control removes the execution step. If dependency scripts don't run, a malicious version that does get resolved sits in node_modules doing nothing until something imports it, and most payloads are written for install time.

npm 12, released in July 2026, does this by default. Dependency lifecycle scripts are blocked unless the root package.json allows them, and npm install-scripts approve writes the allowlist for you, pinned to the version you reviewed:

json
1{
2 "allowScripts": {
3 "esbuild@0.25.10": true
4 }
5}

After approving, npm rebuild runs the newly allowed scripts. When an upgrade changes the version, the approval no longer matches and the script is blocked again until someone looks at it, which is the behaviour you want. On npm 11 you get the same effect with one line in the project's .npmrc and an explicit rebuild for the few packages that need a native build step. Note that the setting also applies to npm rebuild, so the override has to be explicit:

ini
1ignore-scripts=true
bash
1npm ci
2npm rebuild esbuild --ignore-scripts=false

pnpm has blocked dependency build scripts by default since version 10. In pnpm 10 the allowlist is onlyBuiltDependencies; pnpm 11 replaced it with an allowBuilds map in pnpm-workspace.yaml and made strictDepBuilds the default, so an install with an unreviewed build script fails instead of quietly skipping it. pnpm approve-builds edits the map:

yaml
1allowBuilds:
2 esbuild: true
3 core-js: false

postinstall isn't the only door, and two other ones were used this year. A package that ships a binding.gyp gets an implicit install: node-gyp rebuild script, and gyp's <!(...) command expansion runs a shell command while gyp reads the file. That was the Phantom Gyp technique, and it's invisible to a scanner that only reads the scripts field. Some writeups claimed it gets past --ignore-scripts. It doesn't: a test package whose binding.gyp writes a marker file through <!(...) runs on a plain npm install and stays silent with --ignore-scripts, under npm 12's default policy and under pnpm's build blocking, because all three treat the implicit gyp build as an install script.

The TanStack payload used the other door. The malicious versions declared an optional dependency on a git URL, github:tanstack/router#<sha>, that pointed at an orphan commit pushed to a fork. GitHub serves commits from the whole fork network under the parent's name, so no write access to the real repository was needed. npm runs prepare for git dependencies, the script ended in exit 1, and npm then silently dropped the failed optional dependency, leaving nothing in node_modules. The same shape is easy to rebuild locally with a git+file:// dependency, and it behaves exactly as described: the prepare script runs on npm 11's defaults and the dependency vanishes afterwards. It's blocked by --ignore-scripts, by --allow-git=root (only git dependencies declared in your own package.json are fetched), by npm 12 (which changed allow-git to default to none) and by pnpm, whose blockExoticSubdeps setting has defaulted to true since pnpm 11 and refuses git or tarball sources in transitive dependencies.

Blocking scripts has a cost you'll notice in the first week: packages with real native builds or binary downloads, esbuild being the usual first one, need an approval before they work. That's a short list in most projects, and keeping it in a file that shows up in diffs is the point.

04

a release-age cooldown covers the hours before anyone notices

Every incident above was caught and cleaned up within hours, usually by a security vendor or a maintainer, and that gap is what a cooldown uses. If your resolver refuses versions younger than a day, a hijacked release is almost always gone before you could install it.

pnpm 11 turned this on by default: minimumReleaseAge is 1440 minutes, so pnpm won't resolve a version until it's a day old. npm added min-release-age in 11.10, measured in days and off by default. Both have an exclusion list for packages you publish yourself or need immediately:

yaml
1# pnpm-workspace.yaml
2minimumReleaseAge: 4320
3minimumReleaseAgeExclude:
4 - '@yourco/*'
ini
1# .npmrc
2min-release-age=3
3min-release-age-exclude=@yourco/*

To check that the setting does something, install with a deliberately long window. With a two-year window, zod@^3.0.0 resolves to 3.23.8 instead of the latest 3.25.76 on both npm and pnpm, while an excluded package still gets last week's release. Exclusions match package names, not dependency trees: excluding @types/* doesn't exempt undici-types, which @types/node depends on, so a fresh transitive release still fails the install. Your bots need the same rule, and Renovate's minimumReleaseAge holds back update pull requests until a release is old enough.

The trade-off is security fixes. A patch for an actively exploited bug is also a fresh release, and a three-day cooldown means three more days on the vulnerable version. npm handles this loudly: when the window stops npm audit fix from installing a fix, it keeps the old version, warns and exits non-zero, and you add the package to the exclusion list. pnpm audit --fix adds the patched version to minimumReleaseAgeExclude for you. We'd still default to a cooldown of one to three days, because exploited dependency bugs that can't wait a day are rare and hijacked releases are not.

A cooldown does nothing against a slow attack. A malicious version that nobody notices for a month gets through, and so does a maintainer who goes rogue. It's a filter for the common case, not a substitute for the other controls.

05

if you publish packages

The axios post mortem lists "proper adoption of OIDC flow for publishing" as one of its fixes, and npm's registry history shows why that matters: 1.14.0 carries a provenance attestation from CI, while 1.14.1, per StepSecurity's analysis, was published outside the CI pipeline without one. As long as an account or token can publish from a laptop, a stolen credential is enough.

Trusted publishing replaces stored tokens with a short-lived OIDC credential from the CI provider. It needs npm 11.5.1 or newer and works with GitHub Actions and GitLab CI/CD on hosted runners, and with CircleCI. You configure the repository, the workflow file and optionally a GitHub environment on the package's settings page on npmjs.com, give the publish job id-token: write, and provenance is generated automatically for public packages built from public repositories. The step people skip is closing the old path. On the same settings page, "Require two-factor authentication and disallow tokens" blocks token-based publishing while trusted publishing keeps working. Until you set it, any granular token with publish rights is still a second way in. npm already revoked all classic tokens in December 2025 and caps write-enabled granular tokens at 90 days, which helps, but it doesn't remove tokens you created last month.

Trusted publishing protects the credential, not the pipeline, and TanStack showed the difference. The attacker got code running inside the release job, read the OIDC token from the runner's memory and published with it, and the result carried a valid provenance attestation because it really did come from TanStack's workflow. Staged publishing, generally available on npm since May 2026, puts a person back in the loop: npm stage publish (npm 11.15 or newer) uploads the version to a queue, and a maintainer approves it with 2FA before it becomes installable. A trusted publisher can be restricted to stage-only, in which case a plain npm publish from the workflow is rejected. With that restriction, a stolen OIDC token can stage a version but can't release it.

On the consuming side, provenance is still a useful signal as long as you treat it as one bit of information. pnpm's trustPolicy: no-downgrade fails an install when a package's trust level drops compared with earlier releases, which is the pattern axios 1.14.1 showed. It wouldn't have caught TanStack, where the trust level stayed the same. How the workflow itself should be built so that a pull request can't get code into the release job is a separate topic, covered in our GitHub Actions hardening guide.

06

what the pipeline should look like

Put the controls together and a CI setup that holds up looks like this. Dependencies install from the lockfile with scripts off, in a job that has no deploy keys, no registry tokens and a GITHUB_TOKEN limited to contents: read. The few approved build scripts run in that same unprivileged job. Anything that needs real credentials, such as publishing, pushing images or deploying, runs in a later job that receives built artifacts rather than running npm install again. If a payload slips through, it executes somewhere with nothing to steal.

Two more habits close most of the remaining gaps. Restrict outbound network access from build jobs where your platform allows it, because nearly every payload phones home and a blocked connection turns a silent compromise into a failed build that someone looks at. And treat dependency update pull requests as code changes: run them with scripts disabled, attach the lockfile summary from the script above, and don't let a bot auto-merge a version that's a few hours old.

If you only have time for part of this, take it in order of effort: npm ci everywhere with lockfiles copied into Docker builds, scripts off with an allowlist, then a one-day cooldown. Those three would have kept a typical project away from the axios, chalk, Phantom Gyp and TanStack payloads, and none of them needs a vendor or a new service.

Thanks for reading.
Johnny Unar

Written by

Johnny Unar

Keep reading

Stop patching.Start building.

Tell us what's slowing you down. We'll bring coffee and opinions.