top of page

NPM's Security Problem Isn't Going Away

  • Writer: Joseph Rapley
    Joseph Rapley
  • Apr 8
  • 11 min read

If you write JavaScript, you almost certainly use NPM. You probably reach for it without thinking: npm install, hit enter, and carry on. But behind that command is one of the most persistently abused software ecosystems in existence, and the attacks keep coming, nearly every week.

This article covers what NPM actually is, what has gone wrong over the years, why the same classes of attack keep working, and what you can do technically to stop your projects being caught in the crossfire.

This looks like my Ali Express deliveries.
This looks like my Ali Express deliveries.

What is NPM?

NPM stands for Node Package Manager. It ships bundled with Node.js and gives developers access to a public registry of over two million open source packages. Those packages range from single-function utilities (padding a string, parsing a URL) to entire frameworks with hundreds of their own dependencies.

The scale is huge. Packages like chalk, debug, and lodash are downloaded millions of times per week. When you install a single dependency, you often pull in dozens of transitive ones: packages that your package depends on, which themselves depend on others, and so on down the tree. A medium-sized React project can easily have hundreds of packages in its node_modules folder, most of which the developer has never reviewed, or even heard of. Often packages that may not even be needed.

That dependency tree is the attack surface.


A History of Incidents

The problems are not new. What has changed is the scale and sophistication of the attacks.

event-stream (2018)

This was the incident that first put NPM supply chain risk on the mainstream radar. A developer who had become inactive on the event-stream package handed it over to someone who offered to help maintain it. That person added a new dependency, flatmap-stream, which contained obfuscated code targeting a specific Bitcoin wallet app called Copay. The malicious versions were downloaded millions of times over several months before anyone noticed. The attacker had a very specific target in mind, which is partly why the code went undetected for so long.

eslint-scope (2018)

A few months before event-stream, attackers compromised the npm account of an ESLint maintainer and published a backdoored version of eslint-scope. The ESLint post-mortem reveals the maintainer had reused their npm password across other sites and had no MFA enabled.

ua-parser-js (2021)

Three malicious versions of ua-parser-js were published after an attacker gained access to the maintainer's npm account. The package had around eight million weekly downloads at the time. The injected code installed a cryptominer on Linux systems and a password-stealing trojan on Windows. CISA and the FBI issued a joint advisory, which gives you a sense of how serious the blast radius was.

coa and rc (2021)

Within weeks of the ua-parser-js incident, the same attack pattern hit two more packages: coa and rc, both widely used in JavaScript build tooling. Malicious versions were published via compromised accounts, again deploying password stealers. These three incidents in quick succession made it hard to dismiss account hijacking as a fringe risk.

colors.js and faker.js (2022)

This one was different. Marak Squires, the developer behind colors.js and faker.js, intentionally sabotaged his own packages. His stated motivation was frustration with large companies using his work without paying for it. The new versions of colors.js printed an infinite loop of gibberish to the console, breaking any application that imported it. Thousands of projects were affected overnight.

No attacker was involved. The threat came from inside the dependency. It raised an uncomfortable question that the ecosystem still hasn't fully answered: what happens when the maintainer is the problem?

node-ipc protestware (2022)

The developer of node-ipc published versions that detected if a machine's IP address resolved to Russia or Belarus, then attempted to overwrite files on disk with a peace symbol. This was explicitly geopolitical sabotage shipped inside a widely used package. Some developers lost data. The npm registry eventually yanked the affected versions, but the damage was already done.

The Nx / s1ngularity attack (August 2025)

By 2025, attacks had become more targeted. The nx package, a monorepo tool with around four million weekly downloads, was compromised via credential theft. The malicious versions contained a backdoor that specifically hunted for GitHub secrets and API tokens on the developer's machine, then exfiltrated them by creating public repositories on the victim's own GitHub account. That last detail is the clever part, it used the victim's own trusted account as the data exfiltration channel makes the traffic look legitimate.

chalk, debug, and the September 2025 account takeover

On September 8, 2025, a phishing email was sent to a maintainer known as "Qix" who controlled 18 widely-used packages including chalk, debug, ansi-styles, strip-ansi, and supports-color. The email impersonated npm support, using the domain npmjs.help, and claimed his account would be locked unless he updated his 2FA credentials. He clicked through. The attackers gained his token, reset his 2FA, and published malicious versions of all 18 packages within hours.

Those 18 packages had a combined download count of over 2.6 billion per week. The injected code targeted browser-side cryptocurrency wallets, silently redirecting transactions to attacker-controlled addresses.

The Shai-Hulud worm (September and November 2025)

A week after the chalk/debug attack, a different kind of threat appeared. The "Shai-Hulud" worm was the first self-replicating malware ever observed in the npm ecosystem. Once installed, it searched the infected machine for the developer's npm token, then used that token to download every package that developer maintained, inject the malicious code, and republish them all. One compromised developer became the vector for infecting every package in their portfolio.

The second wave, Shai-Hulud 2.0, arrived in November 2025 and was significantly more aggressive. It shifted the malicious code execution from postinstall to preinstall, which runs earlier in the install process and catches more environments. It also added a destructive fallback: if it couldn't exfiltrate credentials successfully, it would attempt to wipe the user's entire home directory. Over 25,000 malicious GitHub repositories were created across roughly 350 compromised accounts before the campaign was contained.

Axios (March 2026)

As recently as March 2026, the axios package, one of the most downloaded HTTP client libraries in the JavaScript ecosystem, was compromised in a supply chain attack attributed by Google to a North Korean threat actor (UNC1069). The attack used a malicious dependency called plain-crypto-js that contained a postinstall hook dropping a remote access trojan. Axios is a foundational package; it sits inside hundreds of thousands of other packages as a transitive dependency.

Why Does This Keep Happening?

The same attacks work, over and over, because the structural conditions that enable them haven't changed much.

The registry is fully open

Anyone can publish a package to npm. There is no mandatory code review, no identity verification beyond an email address, and no approval process. This openness is what made npm grow so fast, and it's also why publishing a malicious package is a matter of minutes.

Single maintainers control critical infrastructure

Many packages that underpin huge portions of the JavaScript ecosystem are maintained by a single person, often working in their spare time with no funding. When that one person's account is phished, or their credentials are stolen, the attacker gets full publish access immediately. There is no second approver, no code review requirement on publish, no delay.

Postinstall scripts run with your permissions

npm allows packages to define lifecycle scripts that run automatically during installation. The postinstall hook in particular runs arbitrary code on your machine the moment you install a package, with whatever permissions your terminal session has. This is a legitimate feature (many packages need to compile native binaries at install time), but it's also the delivery mechanism for most of the malicious payloads described above. Developers have grown accustomed to running npm install without thinking about what code is executing.

Transitive dependencies are invisible by default

When you add a package to your project, you see its name and version. You generally don't see the 40 other packages it depends on, or the packages those depend on. A vulnerability or malicious payload buried three levels deep in your dependency tree is functionally invisible unless you go looking for it. Most developers don't do this.

Version ranges invite surprise updates

The default package.json syntax uses ^ (caret) prefixes, which tell npm to accept any compatible minor or patch update automatically. If a maintainer's account is hijacked and a new malicious patch version is published, projects using ^ ranges will pick it up on the next npm install without any explicit action from the developer.

Account security is inconsistent

For years, npm's MFA requirements were optional. Even after npm made MFA mandatory for the top 500 most-downloaded packages in 2022, phishing attacks have proven that MFA alone is insufficient when the phishing page intercepts the OTP in real time, or when attackers trick maintainers into resetting their 2FA entirely.

If You Use NPM Packages

Various defences exist for teams that utilise npm, but often these aren't a consideration.

Lock your dependency versions

Commit your package-lock.json (or yarn.lock) to version control and treat it as authoritative. This file records the exact resolved version of every package in your tree, not just your direct dependencies.

Never delete or ignore the lockfile. If your .gitignore excludes it, fix that now.

Use npm ci instead of npm install in CI/CD

npm install will happily update packages within your declared ranges and regenerate the lockfile. npm ci does the opposite: it installs exactly what the lockfile specifies, fails if the lockfile doesn't match package.json, and never modifies the lockfile. In a pipeline, npm install should never appear.

Pin exact versions

Go further than the lockfile. Set your package.json versions to exact pinned values rather than ranges. Change "axios": "^1.7.2" to "axios": "1.7.2". The lockfile catches most cases, but exact pinning removes any ambiguity.

You can configure npm to default to exact versions:


npm config set save-exact true

Add a cooldown before pulling new versions

Most supply chain attacks are discovered within 24 to 72 hours. If your CI pipeline automatically updates to new package versions the moment they appear, you will often install a malicious version before the community has had time to identify it.

Enable npm audit in your CI pipeline

npm audit checks your installed packages against the npm advisory database. Run it as a required step in CI and fail the build on high or critical severity findings:


npm audit --audit-level=high

Tools like Snyk or Socket.dev go further, flagging behavioural signals in packages (new postinstall scripts, new outbound network calls, obfuscated code) before they're formally reported as advisories.

Use a private registry or proxy

Instead of pulling packages directly from the public npm registry in CI, route everything through a private registry like Verdaccio, Artifactory, or GitHub Packages. This gives you a caching layer so builds don't break if npm is down, a place to blocklist specific packages or versions, and an audit log of exactly what was installed and when.

In your .npmrc:


registry=https://your-private-registry.example.com

You can configure your private registry to proxy the public npm registry for packages you haven't explicitly approved, while still allowing you to blocklist or approve-list specific ones.

Scope your private packages to prevent dependency confusion

If your organisation publishes internal packages, always scope them under your org name (e.g., @yourcompany/package-name). Unscoped internal package names are vulnerable to dependency confusion attacks, where an attacker publishes a package with the same name to the public registry and npm may resolve to their version instead of yours.

Configure your .npmrc to always resolve scoped packages from your private registry:


@yourcompany:registry=https://your-private-registry.example.com

Harden your CI/CD pipeline environment

Pipelines that run npm install are high-value targets. Keep npm tokens out of environment variables where possible and use your CI provider's native secrets management. Restrict which branches can trigger pipelines that have publish access. Use OpenID Connect (OIDC) token-based auth where your registry supports it, so that no long-lived token needs to be stored at all.

Add egress filtering to your CI runners. Many malicious postinstall scripts exfiltrate data to external webhook services or public GitHub repositories. If your build environment can't make outbound HTTP calls to arbitrary domains, that exfiltration step fails. Block outbound connections except to explicitly approved endpoints.

Monitor what you have installed

Tools like Socket.dev's GitHub app, Snyk, and Dependabot will alert you when a package you depend on has a new advisory or exhibits suspicious behaviour. Wire these into your pull request workflow so that dependency update PRs are automatically scanned before they're merged.

Beyond tooling, do periodic manual reviews of your direct dependencies. Check whether maintainers are still active. Look at the publish history for anything unusual such as a new version after years of inactivity, a sudden change in file count or size, or new dependencies added without explanation. These are common signals of a compromised account.

If You Maintain NPM Packages

Maintainers are the primary target in most of the attacks described above. Compromising one developer with publish access is far more efficient for an attacker than finding a vulnerability in the code itself.

Use phishing-resistant MFA for your npm account

Every maintainer with publish access needs MFA enabled, but the form of MFA matters. SMS is trivially interceptable. Authenticator apps are better, but a determined phishing page can intercept a time-based OTP in real time, which is exactly what happened in the September 2025 attack. Phishing-resistant MFA (FIDO2/WebAuthn), whether that's a hardware key, a platform authenticator like Windows Hello or Touch ID, or a passkey in a password manager, stops that class of attack entirely. The credential is cryptographically bound to the real domain during registration, so it simply won't authenticate against a fake site.

Set your npm account to use a FIDO2 authenticator. If that's genuinely not feasible, use a TOTP app as a minimum, and be extremely sceptical of any email claiming to be from npm support.

Require 2FA on publish


npm profile set auth-and-writes

With this set, every npm publish requires a second factor regardless of how the session was authenticated. An attacker who steals your token still can't publish without the second factor.

Use granular access tokens scoped to specific packages

When generating tokens for CI pipelines or automation, use granular access tokens with the narrowest possible scope. An automation token with publish access to every package you maintain is extremely dangerous if it leaks.

Go to npmjs.com, open Access Tokens, select "Generate New Token", and choose "Granular Access Token". Scope it to only the packages that pipeline needs to publish, and set an expiry date.


# Review and rotate tokens regularly
npm token list
npm token revoke <token-id>

Revoke tokens you no longer actively need. If a token leaks then rotate it immediately.

Publish provenance attestations

npm now supports publishing provenance, which cryptographically links a package version to the specific CI run and source commit that produced it. Anyone installing your package can verify it was built from your repository and not from an attacker's machine.

If you publish from GitHub Actions, add the --provenance flag:


npm publish --provenance

This requires the workflow to have id-token: write permissions. Once enabled, your package page on npmjs.com will show a verified provenance badge, and consumers can inspect the build attestation directly.

Add a .npmignore or files field to control what ships

Many accidental leaks of sensitive files (.env, private keys, internal configs) happen because maintainers don't explicitly control which files get published. Use the files field in package.json to whitelist only what belongs in the package:


{
  "files": [
    "dist/",
    "src/",
    "README.md"
  ]
}

Run npm pack --dry-run to see exactly what would be included before you publish.

Be cautious about handing over packages

The event-stream attack in 2018 started when the original maintainer transferred the package to someone who offered to help. Handing control of a widely-used package to someone you've only encountered online carries real risk. If you're stepping back from a project, archiving it or marking it deprecated is safer than transferring it to an unknown contributor. If you do want to bring on co-maintainers, add them as collaborators with publish access rather than transferring full ownership, and verify their identity through multiple channels before doing so.

A Note on Trust

Fundamentally, every package you install is code you are trusting to run on your machines, and potentially your users' machines. That trust is often extended without much thought, and attackers are increasingly aware of exactly how much value sits behind it.

None of the mitigations above are complicated. Most take minutes to configure. The challenge is that they require treating dependency management with the same seriousness you'd give to any other part of your infrastructure, which historically hasn't been the norm in JavaScript development.

The attacks described in this article, from event-stream in 2018 to Axios in 2026, all relied on developers installing packages without scrutiny, running postinstall scripts without review, and using accounts without strong authentication. The technical fixes are already well understood. The gap is in applying them consistently.

 
 
bottom of page