How to block malicious npm packages

Five npm settings that stop the common attacks, and what they miss.

A malicious npm package is a release running code through an install script, and the release usually arrives through a stolen maintainer account, a lookalike name or dependency confusion. Five npm settings close most of the paths: ignore-scripts, min-release-age, npm ci, a scope mapping and npm audit signatures. Each section below covers what the setting does, what the setting misses, and where a package firewall such as Chainsaw fits once all five settings are on.

Questions and answers

How do malicious npm packages get onto a machine?

Malicious npm packages get onto a machine mostly through the install itself. An install script is the usual way in: an npm package can declare preinstall, install or postinstall scripts in package.json, and npm runs the scripts the moment the package lands, before any of your code imports the package. Both Shai-Hulud worm waves of 2025 ran from an install script. The malicious version usually arrives in one of four ways. First, a stolen maintainer account publishes a new release of a popular package. Second, a lookalike name such as lodahs for lodash catches a typo (typosquatting). Third, a brand-new package is pulled in as a dependency of a package you did choose. Fourth, a public package takes the name of one of your private ones (dependency confusion). In each case the install script runs on the developer’s laptop or the CI runner before anyone reviews the code.

Which npm settings help?

Five npm settings and habits help, and none needs a new tool. First, ignore-scripts=true in .npmrc stops npm running the install scripts declared in package.json, the way both Shai-Hulud worm waves of 2025 got in. Second, min-release-age=1 makes npm install only versions published more than a day ago, which keeps out most malicious releases because they are often removed within hours. Third, npm ci in CI installs exactly the versions in package-lock.json. Fourth, a scope mapping such as @yourco:registry= sends every private @yourco/* request to your own registry. Fifth, npm audit signatures checks registry signatures and provenance attestations. Each setting is described below with the line to copy.

Turn off install scripts where you can

# .npmrc
ignore-scripts=true

ignore-scripts is the npm setting that closes the most common way in for a malicious package (how install script attacks work). With ignore-scripts, npm does not run the preinstall, install or postinstall scripts declared in package.json files. Both Shai-Hulud worm waves of 2025 ran from an install script, so the setting closes the usual way in. Commands which name a script, such as npm test or npm run, still run the named script, but not the matching pre- or post-scripts. Some packages need install scripts to build native code; run those builds explicitly, or allow the scripts per project. The setting is easier to switch on in CI than on laptops.

Wait before installing brand-new versions

# .npmrc
min-release-age=1

min-release-age is an npm setting that installs only versions published more than the given number of days ago. Malicious releases are often reported and removed within hours, so even one day keeps most malicious releases out. If no version of a dependency is old enough, the install fails with an error instead of picking a newer one. The older before setting does the same with a fixed date; if both are set in the same place, before wins. Check whether your npm version has min-release-age (the setting is in the npm 11 config reference) before relying on the setting in CI.

Install from the lockfile in CI

npm ci is the npm command for frozen, repeatable installs, and the right default for CI. According to the npm documentation, npm ci needs an existing package-lock.json, exits with an error if package.json and the lockfile disagree, removes any existing node_modules first, and never writes to package.json or the lockfile. A build then cannot quietly pick up a version published after the last lockfile review. The lockfile itself still needs review: npm ci installs whatever the lockfile pins, including a malicious version already committed to the repository. Review lockfile changes in pull requests the same way as code.

Route your private scopes to your own registry

# .npmrc
@yourco:registry=https://npm.yourco.example/

A scope mapping is a one-line .npmrc entry which sends every package under one scope to one registry. With @yourco:registry= set, every @yourco/* request goes to your registry and never to the public one, which blocks the dependency-confusion trick of publishing a public package under your private name. The mapping works with any registry that serves the npm protocol, such as Verdaccio, Artifactory or Nexus. The mapping only protects packages under your scope. An unscoped private name can still be claimed on the public registry, so move private packages under the scope before relying on the mapping for dependency-confusion protection.

Verify signatures and provenance

npm audit signatures is the npm command that checks the registry signatures of installed packages and, where a package publishes provenance attestations, the attestations too. According to the npm documentation, the command needs a recent npm, often newer than the version which ships with Node.js. Running the check in CI after npm ci covers every build, and adding the flags --json --include-attestations prints the full attestation bundles for each package. The check will not catch a malicious release signed by a hijacked account, because the registry signs a hijacked release like any other, but the check does catch tampering in transit.

What do these settings not cover?

The npm settings above do not look at what a package does. A lookalike name with no install script, a release that quietly adds a dependency, and a publisher change on a popular package all pass ignore-scripts, min-release-age and npm ci. A release-age wait only delays a malicious version: once the version is older than the cutoff, npm installs the version as usual. npm audit signatures does not catch a malicious release signed by a hijacked account. The settings also work per machine and per project, across .npmrc files and CI scripts, so each one is easy to forget or switch off on a single laptop. Finally, the npm settings cover npm only, while most teams also install from PyPI, Maven, Go and Docker, each with different options.

Where does a package firewall fit?

A package firewall is a registry proxy that every npm install pulls through, so the same checks apply once for the whole organisation instead of once per .npmrc. Chainsaw applies the same model to npm and 15 other ecosystems, including PyPI, Maven, Go and Docker, and runs up to all 25 of its detection signals on npm. For npm, Chainsaw’s defaults hold new versions for 48 hours and hide too-new versions, so version ranges resolve to an older release. Chainsaw refuses packages first published less than 1 day ago and install scripts that fetch remote URLs, matches lookalike names against popular packages, checks the OpenSSF malicious-packages feed, and flags behavioural signals such as publisher changes. Every rule can run in monitor mode before Chainsaw refuses anything.

Keep the npm settings. The settings still protect a machine that bypasses the firewall. What a package firewall is and how it works.

Sources

Settings drift, machines differ

Apply the same checks to every install in your org

Chainsaw is a package firewall that applies the same checks to every npm install in the organisation, and to 15 other ecosystems including PyPI, Maven, Go and Docker. All 25 detection signals are on every plan, including Free, and every rule can run in monitor mode first. Start a free org in monitor mode and see which npm installs Chainsaw would refuse, then turn rules on one at a time.