The pattern is consistent across recent incidents: an attacker phishes a maintainer’s npm credentials, publishes a malicious version of a package with heavy download volume, and a postinstall script steals credentials from every machine that installs it — including CI runners, which then publish more compromised packages. That last step is what turned a nuisance into a self-propagating problem.
This category has escalated sharply. Early incidents were single packages with crude payloads. Recent ones involve worms that harvest tokens from the machines they land on and use them to republish, spreading across hundreds of packages without further attacker involvement. The defences are not exotic, but they do require changing habits that most teams have never questioned.
Table of contents
- How the attacks actually work
- The defences that measurably help
- Reducing the surface itself
- Protecting CI specifically
- What to do when you are affected
- What does not help much
- How this fits the rest of the stack
- FAQ
How the attacks actually work
- Initial access. Usually a phishing campaign against maintainers, often a convincing email about account verification. Sometimes a hijacked expired domain behind a maintainer’s email address, or dependency confusion against an internal package name.
- Publish. A new version goes out under the compromised account. It is a minor or patch bump, so it lands inside everyone’s existing version ranges automatically.
- Execute. A
postinstallscript runs duringnpm install— on developer laptops and, more valuably, on CI runners. - Harvest. The payload scans for credentials: npm tokens, cloud provider keys, environment variables, SSH keys, wallet files.
- Propagate. With harvested npm tokens, the malware publishes compromised versions of packages those tokens can publish to. This is the step that makes recent incidents self-replicating.
- Monetise. Cryptocurrency theft by patching web APIs to swap wallet addresses, or straightforward credential resale.
Two things to take from that sequence. The blast radius is not your direct dependencies — it is everything transitively reachable, which for a typical application is hundreds or thousands of packages. And CI is the highest-value target, because it holds tokens with publish rights and cloud credentials, and nobody is watching it interactively.
The defences that measurably help
- Commit your lockfile and install from it.
npm ciinstalls exactly what the lockfile says and fails ifpackage.jsonand the lockfile disagree.npm installin CI can resolve new versions, which is precisely the exposure you are trying to close. - Disable lifecycle scripts by default.
npm ci --ignore-scriptsprevents postinstall from running, which blocks the execution step of nearly every incident. Some packages genuinely need scripts for native builds — allow those specifically rather than allowing everything. - Delay adopting brand-new versions. Malicious releases are usually identified within hours. Waiting a day or two before taking a new version removes most of the exposure at almost no cost, and some tooling supports this as a cooldown setting.
- Audit in CI and fail on high severity.
npm audit --audit-level=highcosts nothing and catches known-bad versions. - Pin exact versions for anything sensitive. Ranges are convenient and they are also what lets a malicious patch release in automatically.
- Scope CI tokens narrowly and expire them. A publish token that only publishes one package limits what a compromise can do.
- Use provenance and signature verification where the registry supports it, so you can check a package was built from the source it claims.
The scripts one is the highest-leverage change on that list and the least adopted. It breaks the execution stage directly, and the cost is enumerating the handful of dependencies that genuinely need a build step.
Reducing the surface itself
Every dependency is a trust decision, and most projects have made thousands of them without noticing.
npm ls --all | wc -l
npm ls --prod --all | wc -l
npm why some-package
npx depcheck
Run those on a project you have not audited. The transitive count is usually much higher than anyone guesses, and a meaningful share is dead weight — packages pulled in by something you replaced two years ago, or one-function utilities that the language now provides natively.
Questions worth asking before adding a dependency:
- Is this replaceable with a few lines of code or a built-in? Small utility packages carry the same supply chain risk as large ones.
- How many maintainers does it have, and when was it last published? A single-maintainer package that has not been touched in three years is a hijacking candidate.
- How many transitive dependencies does it drag in? One direct dependency can bring forty.
- Does it need install scripts? That is a meaningful signal.
- Is it a dev dependency? Those run in CI too, so they are not free.
Removing dependencies is unglamorous work with a real payoff: it is the only defence that reduces exposure permanently rather than mitigating it.
Protecting CI specifically
CI is where a compromised install does the most damage, because it holds the credentials that let an attack spread.
- Least privilege on every token. A build that does not publish does not need a publish token. A build that does should use a token scoped to that package alone.
- Short-lived credentials. OIDC-based authentication to cloud providers issues a token per run instead of storing a long-lived key.
- Pin your CI actions and images by digest, not by a mutable tag. A tag can be repointed; a digest cannot.
- Restrict egress from build runners where your CI supports it. A payload that cannot reach an external endpoint cannot exfiltrate what it found.
- Separate build from deploy. A build stage with no deployment credentials limits what a compromised dependency can reach.
- Do not echo secrets into logs, and verify your CI masks them.
set -xin a script with a token in the environment is a leak.
Egress restriction is the underused one. The harvest stage is worthless without a way to send data out, and most build jobs legitimately need to reach only a package registry and your own services.
What to do when you are affected
An advisory names a package you depend on. The sequence, in order of urgency:
- Establish whether you actually installed the bad version.
npm ls <package>against your lockfile. The version range in package.json is not the answer; the lockfile is. - Assume every credential on affected machines is compromised. Not just npm tokens — cloud keys, environment variables, SSH keys, anything readable by the build user.
- Rotate them. All of them, starting with anything that grants publish or deploy rights. This is the step people delay while investigating, and it is the one that should happen first.
- Check your own published packages for versions you did not publish. This is how the propagation stage shows up on your side.
- Review recent deploys. A compromised build may have shipped modified artifacts.
- Pin to a known-good version, or remove the dependency.
- Check registry and cloud audit logs for unexpected activity from your tokens.
The instinct is to investigate before rotating. Reverse it. Rotation is cheap and fast; investigation takes hours, and every hour a compromised token remains valid is an hour of exposure.
What does not help much
Worth naming, because effort spent here is effort not spent on the list above.
- Reading the source of every dependency. Not feasible at the scale of a real dependency tree, and the malicious code is usually obfuscated and in a build artifact rather than the repository anyway.
- Avoiding npm for another package manager. The registry is the target, and the alternatives install from the same one. Some have better defaults around scripts, which helps — but the registry risk is shared.
- Vendoring everything. It freezes your exposure at the moment you vendored, which means you also freeze the security fixes.
- Waiting for the registry to solve it. Provenance, two-factor requirements for publishing, and trusted publishing are all real improvements and all partial. They reduce the probability; they do not remove the need for lockfiles, script controls, and scoped tokens.
The realistic position is that you cannot eliminate this risk, and you can substantially reduce both the probability of being hit and the damage when you are. Lockfile plus npm ci plus --ignore-scripts plus scoped short-lived CI tokens covers most of it, and none of those requires new tooling.
How this fits the rest of the stack
Most of the practical damage in these incidents happens in the build, which is why it matters that a build runs in a defined environment with only the credentials it needs rather than on a machine holding everything. On RunxBuild a service builds from its connected repository with the build log attached to the deploy it produced, environment variables scoped per service rather than baked into an image, and the previous deploy kept available so a bad artifact can be rolled back rather than rebuilt under pressure. Deploying from GitHub on RunxBuild covers the repository connection and what each push does. When you are pricing separate staging and production environments so a suspect build never lands straight in front of users, the RunxBuild hosting calculator shows the service, database, storage, and bandwidth as individual lines.
Useful related references:
- npm Not Found: Every Reason It Happens and the Fix for Each One
- npm install a Specific Version: Pinning, Ranges, and the Lockfile
- npm Clean Install: When Deleting node_modules Is Right, When
npm ciIs Right, and When Both Are Slowing You Down - Services on RunxBuild
FAQ
How do npm supply chain attacks work?
An attacker compromises a maintainer account, usually through phishing, and publishes a malicious minor or patch version that falls inside everyone’s existing version ranges. A postinstall script then harvests credentials from machines that install it — including CI runners, whose npm tokens are used to publish further compromised packages.
Does npm audit protect me from supply chain attacks?
Partially. It flags known vulnerabilities in your installed versions, so it catches a malicious release once it has been identified and published as an advisory. It does nothing about a compromise that has not been discovered yet, which is why lockfiles and disabled install scripts matter more.
Should I disable npm install scripts?
Yes, as a default. npm ci --ignore-scripts blocks the execution stage of nearly every recent incident. Some packages genuinely need scripts for native builds — enumerate those and allow them specifically rather than allowing lifecycle scripts across the whole tree.
What should I do if a package I use was compromised?
Rotate every credential on the affected machines first, before investigating — npm tokens, cloud keys, environment variables, SSH keys. Then check npm ls <package> against your lockfile to confirm which version you installed, review your own published packages for releases you did not make, and pin to a known-good version.
Does using a different package manager avoid the risk?
Not fundamentally. The registry is the target and the alternatives install from the same one. Some have safer defaults around lifecycle scripts and stricter lockfile handling, which genuinely helps, but the underlying exposure to a compromised package is shared across all of them.