How to Protect a Node.js Project from npm Supply-Chain Attacks
chalk and debug, Shai-Hulud, axios, TanStack, ChainDrop: what actually happened in 2025 and 2026, and a practical checklist with a sample .npmrc, install-script allow-listing, release-age delays, locked-down GitHub Actions and what to do if you installed a bad version.

To protect a Node.js project from npm supply-chain attacks, make every install reproducible and slow down new code. In practice that means six things:
- Commit the lockfile and install with
npm ci. - Stop dependency install scripts from running unless you have approved them.
- Refuse versions that are only hours old, using a release-age delay.
- Block git and tarball-URL dependencies.
- Give CI jobs the smallest permissions possible, and pin actions to commit SHAs.
- If you publish packages, use trusted publishing instead of long-lived tokens.
None of this makes you immune. It does turn most of the 2025 and 2026 attacks from "we were compromised" into "we were never on the bad version". And if you did install a compromised version, treat the machine as compromised and rotate every secret from a clean device.
Here is what happened, and the exact settings I recommend for an Express or MEAN project.
The confirmed incidents, 2025 to 2026
This list only includes incidents with an official advisory or a first-party write-up. There were more campaigns, but these are the ones I could verify.
- 8 September 2025: chalk and debug. A maintainer account was phished, and malicious versions such as
chalk@5.6.1anddebug@4.4.2were published. GitHub's advisory describes "a browser-based interceptor" that swapped cryptocurrency wallet addresses in transactions. - 14 September 2025: Shai-Hulud. GitHub called it "a self-replicating worm". It spread through compromised maintainer accounts, injected malicious post-install scripts and stole secrets. GitHub removed more than 500 compromised packages and blocked uploads that matched its indicators of compromise.
- 24 November 2025: the second Shai-Hulud wave. GitHub published a malware advisory for three versions of
@zapier/zapier-sdkthat day. Security vendors reported that the wave also hit packages from AsyncAPI, ENS, PostHog and Postman, and that it ran from a preinstall script, which executes before anything else in the install. - 31 March 2026: axios. Malicious
axios@1.14.1andaxios@0.30.4were published. They pulled in an injected dependency,plain-crypto-js, whose postinstall script downloaded a cross-platform remote access trojan. Both versions have since been removed from the registry. - 11 May 2026: TanStack. 84 malicious versions across 42
@tanstack/*packages were published within about six minutes. The attacker chained three things: apull_request_targetworkflow, GitHub Actions cache poisoning, and extraction of the OIDC token from runner memory. That let them publish through the project's legitimate trusted-publishing workflow. No npm tokens were stolen. The payload came in through anoptionalDependenciesentry pointing at agithub:URL, whosepreparescript ran at install time. - 4 August 2026: ChainDrop. A worm that started with
keyv@6.0.0. GitHub has malware advisories forkeyv@6.0.0,flat-cache@6.1.24andcacheable@2.5.1. StepSecurity reported a preinstall dropper that steals npm, GitHub, cloud and AI-tool credentials. It also plants hooks in.claude/settings.jsonand.vscode/tasks.json. The malicious versions carried valid provenance, because they were built by the projects' own release workflows.
Two patterns repeat. First, the code runs at install time, through a lifecycle script, before your app ever imports anything. Second, bad versions are usually caught within hours. In the TanStack case, the postmortem says the compromise was detected about 26 minutes after the first publish, and npm removed the packages between about 2 hours 53 minutes and 4 hours 35 minutes after publication. Both patterns point to the same defences.
1. Lockfile and npm ci, always
Commit package-lock.json and use npm ci everywhere that is not a developer adding a package: CI, Docker builds and deploy scripts. According to the npm docs, npm ci fails if the lockfile and package.json disagree, never writes to either file, and removes any existing node_modules first. A compromised version can then only reach you through a lockfile change, and a lockfile change shows up in a pull request.
Review lockfile diffs like code. A new transitive dependency you have never heard of (as plain-crypto-js was for axios users) is exactly the kind of change worth stopping on.
2. Make install scripts opt-in
Most packages do not need preinstall, install or postinstall scripts. npm now has a policy for this, the allowScripts field in your project's package.json, and it behaves differently depending on your npm version:
- npm 11.16 and later: the policy is advisory. Scripts still run, but npm lists the packages whose scripts you have not reviewed. Set
strict-allow-scripts=trueand any unreviewed package with install scripts fails the install instead. - npm 12 (released 8 July 2026): dependency lifecycle scripts are blocked by default unless the root package's
allowScriptsallows them.
Do not write allowScripts by hand. Let npm generate it:
# List dependencies with install scripts that you have not reviewed yet
npm approve-scripts --allow-scripts-pending
# Approve specific packages after reviewing them (writes pinned pkg@version entries)
npm approve-scripts sharp
# Explicitly deny one, so it stays blocked even after "approve --all"
npm deny-scripts some-telemetry-package
# npm 12 spelling of the same commands
npm install-scripts ls
npm install-scripts approve sharp
Approvals are pinned to the version you reviewed by default, so a new release has to be approved again. That is the behaviour you want. In my own API the one dependency that really uses an install hook is mongodb-memory-server, which downloads a MongoDB binary for tests. The production Docker image skips that download with an environment variable.
If you are on an npm version without allowScripts, the blunt alternative is ignore-scripts=true. Then rebuild only what needs it, for example npm rebuild sharp. Be aware that ignore-scripts also skips the pre and post hooks of your own npm scripts. npm test still runs, but pretest does not.
3. Add a release-age delay
Malicious versions tend to live for hours, not weeks, so simply not installing anything published in the last few days avoids most of them. npm added min-release-age in version 11.10.0. It takes a number of days, and npm then only installs versions that were published more than that many days ago. min-release-age-exclude (npm 11.17.0 and later) exempts packages you publish yourself.
Two points from the docs that are easy to miss:
- The setting changes how npm builds the dependency tree when it resolves versions.
npm ciinstalls what the lockfile already records, so in practice the protection applies when the lockfile is written, and the lockfile diff is where you review it. The npm docs do not describe how the setting interacts withnpm ci, so check that on your npm version. - If the window blocks a security fix,
npm audit fixkeeps the vulnerable version, warns you and exits with a non-zero code. For an urgent fix, exclude that package or relax the setting for that one command.
Check which npm you have. Node 26.13.0 ships npm 11.20.0 and Node 24.22.2 ships npm 11.19.1, so both support every setting in this post. Node 22.23.4 still ships npm 10.9.9, which supports none of them. This is one more reason to read which Node.js version to run in production.
4. Block git and tarball dependencies
allow-git (npm 11.9.0 and later) and allow-remote (npm 11.14.0 and later) control whether npm may fetch dependencies from git repositories or arbitrary tarball URLs. Both default to none in npm 12. On npm 11, set them yourself. The TanStack payload arrived through exactly such a github: dependency, which allow-git=none refuses to fetch.
A sample .npmrc
Put this in the project root and commit it. Every key below is documented on the npm 11 config page.
# .npmrc - project-level supply-chain settings (npm 11.17 or later)
# Only install versions published at least 7 days ago
min-release-age=7
# Packages you publish yourself can skip the delay
# min-release-age-exclude[]=@your-scope/*
# No git or tarball-URL dependencies (already the default in npm 12)
allow-git=none
allow-remote=none
# Fail the install if a dependency has install scripts you have not approved
# (run "npm approve-scripts" first; npm 12 blocks unapproved scripts by default)
strict-allow-scripts=true
# Refuse packages that say they do not support your Node version
engine-strict=true
# Save exact versions instead of ^ranges when adding a dependency
save-exact=true
On npm 12, unknown keys in .npmrc only produce a warning unless you set strict-npmrc=true. Check the warnings once, because a typo in a security setting silently disables it.
Tokens and 2FA: what changed on npm
After Shai-Hulud, GitHub tightened how npm accounts publish. The changes rolled out in stages:
- 5 November 2025: new classic tokens could no longer be created. Granular tokens with write access were capped at a 90-day lifetime, and new write tokens enforce 2FA by default.
- 9 December 2025: all remaining classic tokens were permanently revoked.
npm loginnow gives you a two-hour session token, and 2FA is enforced for publishing during that session.
GitHub's plan also announced moving users from TOTP codes to FIDO-based 2FA, meaning security keys and passkeys. Phishing is how the chalk and debug maintainer was compromised, and a phishing-resistant second factor is the direct answer to that.
What this means for you:
- If you only consume packages: CI should not need an npm token at all. For private packages, use a read-only granular token scoped to the packages it needs.
- If you publish packages: use trusted publishing (OIDC) from GitHub Actions, GitLab CI/CD or CircleCI. It needs npm 11.5.1 or later and Node 22.14.0 or later. Then, in the package settings, choose "Require two-factor authentication and disallow tokens".
Provenance helps, but know its limits
npm audit signatures checks the registry signatures of what you installed, and it also verifies provenance attestations, which link a package to the source commit and the workflow that built it. Run it in CI. It is cheap and catches tampering between the build and your machine.
It does not prove a release was authorised. The TanStack versions came out of the real release workflow, and the ChainDrop versions carried valid provenance. StepSecurity put it well: "Provenance proves which commit was built. It cannot prove the commit was authorized." Treat provenance as one signal, not a verdict.
Lock down CI
CI runners hold the most valuable secrets, and TanStack showed how a workflow can be turned against its own repository. The rules I follow:
- Set
permissions: contents: readat the top of every workflow, and grant more only to the job that needs it. - Pin third-party actions to a full commit SHA. GitHub's own hardening guide recommends this, because a tag can be moved.
- Avoid
pull_request_target. If you must use it, never build or run code from the pull request in that workflow. For pull requests from forks, plainpull_requestgets a read-only token and none of your other secrets. - Do not cache dependencies in release or publish jobs. npm's own trusted-publishing example sets
package-manager-cache: falsewith the comment "never use caching in release builds". - Keep secrets out of the install step. Install and test in a job that has no deploy credentials, and deploy from a separate job.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm audit signatures
- run: npm audit --audit-level=high
- run: npm test
persist-credentials: false stops the checkout action from keeping the GitHub token around for later steps, so a malicious install script has less to reuse. The SHAs above are the v7.0.1 and v7.1.0 tags as of 5 November 2026. Let Dependabot bump them, so you are not tempted to go back to tags.
Dependabot and Renovate settings
Automated update tools are where new versions enter your repository, so give them the same delay. Dependabot applies a default cooldown of 3 days to version updates even if you configure nothing, and the cooldown option lets you lengthen it. Security updates are not delayed by cooldown, which is what you want.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
semver-major-days: 14
groups:
minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
On Renovate, the built-in security:minimumReleaseAgeNpm preset waits until an npm version is three days old before raising the update. You can also set minimumReleaseAge yourself:
{
"extends": ["config:recommended", "security:minimumReleaseAgeNpm"],
"packageRules": [
{
"matchDatasources": ["npm"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict",
"prCreation": "not-pending"
}
]
}
If you installed a compromised version
The GitHub advisories for these packages say it plainly: any computer that installed one "should be considered fully compromised", and all secrets on it should be rotated "from a different computer". In practice:
- Confirm exposure. Search lockfiles in every repository and branch, for example with
npm ls axios, or withgit log -p -- package-lock.jsonto see when a version appeared. Check CI logs for installs during the attack window. - Isolate the machine or runner. Take it off the network. Self-hosted runners should be rebuilt, not cleaned.
- Remove persistence first. For ChainDrop, StepSecurity warns to remove the token monitor before revoking the GitHub token, because revoking it triggers the payload. Check
.claude/settings.jsonand.vscode/tasks.jsonin your projects for hooks you did not add. - Rotate everything the install could read, from a clean device: npm and GitHub tokens, SSH keys, cloud keys, the MongoDB connection string, and the
JWT_SECRETand other values in.env. Rotating a JWT secret signs every user out. That is expected, and the JWT decoder helps you confirm which tokens are old. - Review account activity. Look for new GitHub repositories, workflows, deploy keys or OAuth apps, and new npm versions published under your name. TanStack's advisory also recommends checking cloud audit logs from the install time onwards.
- Reinstall from a known-good lockfile with
npm ci --ignore-scripts, then turn the settings from this post on so it does not happen again.
The short version
- Commit
package-lock.json, runnpm ciin CI and Docker, and review lockfile diffs. - Use the
.npmrcabove:min-release-age,allow-gitandallow-remoteset tonone, andstrict-allow-scripts. - Approve install scripts per package with
npm approve-scripts. - Delay Dependabot and Renovate updates by a few days.
- Use read-only CI permissions, SHA-pinned actions, no
pull_request_target, and no caching in release jobs. - Use trusted publishing and a security key if you publish packages.
- If you were hit, assume compromise, remove persistence, then rotate from a clean machine.
This hardening is part of how I set up projects in my Node.js and Express API development and MEAN stack development work. If you want a second pair of eyes on your pipeline, get in touch.
Sources
- GHSA-2v46-p5h4-248w: Malware in chalk and GHSA-4x49-vf9v-38px: Malware in debug
- GitHub: Our plan for a more secure npm supply chain (Shai-Hulud, 22 September 2025)
- GHSA-hxq5-5w35-rmpp: Malware in @zapier/zapier-sdk (24 November 2025)
- GHSA-fw8c-xr5c-95f9: Malware in axios and GitLab advisory entry
- TanStack postmortem and GHSA-g7cv-rxg3-hmpx
- StepSecurity: ChainDrop npm worm and GHSA-3p9h-f68w-m6fx: Malware in keyv
- GitHub changelog, 5 November 2025 and 9 December 2025
- npm config reference (v11) and (v12)
- npm approve-scripts and npm install-scripts
- npm CLI release notes (versions that added each setting, npm 12.0.0 defaults)
- npm ci and npm audit
- npm trusted publishing
- GitHub Actions secure use reference and Securely using pull_request_target
- Dependabot options reference (cooldown)
- Renovate minimumReleaseAge and security presets




