The Axios Supply Chain Attack: What Happened, How npm Works, and How to Lock Your Dependencies
What Happened
In March 2026, attackers used compromised credentials of a lead Axios maintainer to publish two poisoned packages to npm:
axios@1.14.1axios@0.30.4
Both versions inject a new dependency — plain-crypto-js@4.2.1 — that is never imported anywhere in the legitimate Axios source code. A postinstall script (node setup.js) runs automatically during npm install and downloads an obfuscated dropper that retrieves a platform-specific Remote Access Trojan (RAT) payload for macOS, Windows, or Linux.
Any CI pipeline, developer machine, or deployment environment that ran npm install and resolved to either of those versions may have had all secrets available in that environment (cloud keys, deploy keys, npm tokens, etc.) exfiltrated to an interactive attacker.
Together the two compromised packages represent up to 100 million weekly downloads — making the potential blast radius enormous.
A key detail: neither 1.14.1 nor 0.30.4 appear in the official Axios GitHub repository tags. This means there was no code review, no PR, no audit trail — just a malicious publish to the npm registry using stolen credentials.
Users of apps built with Axios are not directly at risk. The infection path is the install/build step on developer machines and CI runners, not application runtime in the browser.
Source: Malwarebytes — Axios supply chain attack chops away at npm trust
Background: What Is Axios?
Axios is a promise-based HTTP client for Node.js and the browser. It lets developers make requests like "fetch my messages from the server" or "submit this form to an API" with minimal boilerplate, handling things like JSON serialization, error normalization, and request/response interceptors automatically.
Because it works in both browser and Node.js environments, it became a standard building block across the JavaScript ecosystem. Frameworks like React, Vue, Angular, Electron, and React Native all have large communities that rely on it — directly or through dependencies they pull in.
Think of it like the plumbing in a house: you never see it, but it is always there moving data where it needs to go. You do not need to know it exists until a leak occurs.
How Semantic Versioning (SemVer) Works
Npm packages use Semantic Versioning, or SemVer, to communicate what kind of change a new release contains. A version number has three parts:
MAJOR.MINOR.PATCH
1 . 6 . 8
| Part | Incremented when... | Example |
|---|---|---|
MAJOR | Breaking changes that require consumers to update their code | 0.x.x → 1.0.0 |
MINOR | New features added in a backward-compatible way | 1.6.0 → 1.7.0 |
PATCH | Backward-compatible bug fixes | 1.6.7 → 1.6.8 |
The convention is a contract between package authors and consumers. When you declare a dependency, you use version ranges to express how much you trust future updates to remain compatible with your code.
The special behavior of 0.x.y
The 0.x.y range is treated differently than >=1.0.0. Because a major version of 0 signals that a package is still in initial development, the MINOR version acts like a breaking-change indicator. So:
^0.21.1means>=0.21.1 <0.22.0— only patch-level changes are accepted^1.6.1means>=1.6.1 <2.0.0— any minor or patch update within1.x.xis accepted
This distinction turns out to be critical in the Axios incident, as you will see below.
What Is a Transitive Dependency?
When you install a package, that package almost certainly has its own dependencies. Those dependencies have their own dependencies, and so on. Everything beyond your direct dependencies and devDependencies in package.json is a transitive dependency — a package you never explicitly asked for, but that ends up in your node_modules because something you did ask for needed it.
your project
└── @acme/web-sdk ← direct dependency
└── @acme/dev-tools ← transitive dependency
└── axios@^1.3.2 ← transitive dependency (two levels deep)
You may have never typed axios anywhere in your project, but it is running in your pipeline. This is the core reason supply chain attacks are so dangerous: you are implicitly trusting the entire tree, not just the packages you chose directly.
How npm install Works
When you run npm install, npm does the following:
- Reads
package.jsonto find your declared direct dependencies and their version ranges. - Reads
package-lock.json(if it exists) to find previously resolved exact versions for every package in the tree. - If
package-lock.jsonexists and is consistent withpackage.json, npm installs exactly what the lockfile says — no network resolution, no version picking. - If
package-lock.jsondoes not exist, or a new dependency was added, npm resolves ranges against the npm registry, picks the highest version that satisfies each range, writes the result into a new or updatedpackage-lock.json, and installs.
The lockfile is your snapshot of a known-good dependency tree. As long as it exists and is committed to version control, npm install is deterministic — every developer and every CI run gets identical package versions.
The danger arises when the lockfile is missing, deleted, or regenerated — for example, after a git clean, a fresh CI checkout that does not cache the lockfile, or an npm install <new-package> that triggers re-resolution.
How the Caret (^) Symbol Impacts Updates
The caret is the default version prefix that npm install <package> writes into package.json. It expresses a range rather than a pinned version, and its behavior depends on where the first non-zero digit appears:
| Range written | Expands to | What npm can pick |
|---|---|---|
^1.6.1 | >=1.6.1 <2.0.0 | Any 1.x.y where x.y >= 6.1 |
^0.21.1 | >=0.21.1 <0.22.0 | Only 0.21.y where y >= 1 |
^0.0.3 | >=0.0.3 <0.0.4 | Only 0.0.3 exactly |
~1.6.1 | >=1.6.1 <1.7.0 | Only 1.6.y where y >= 1 |
Why This Mattered in the Axios Attack
The project has three consumers of Axios, each with a different range:
| Package | Range declared | Expands to | Could it reach the malicious version? |
|---|---|---|---|
@acme/legacy-sdk | ^0.21.1 | >=0.21.1 <0.22.0 | No — 0.30.4 is outside this window |
@acme/dev-tools | ^1.3.2 | >=1.3.2 <2.0.0 | Yes — 1.14.1 satisfies this range |
wait-on | ^1.6.1 | >=1.6.1 <2.0.0 | Yes — 1.14.1 satisfies this range |
The 0.x consumer was protected by accident: the SemVer semantics of ^0.21.x kept it strictly within 0.21.y, a range that can never reach 0.30.4. The 1.x consumers were fully exposed — any fresh npm install that lacked a lockfile could have fetched the poisoned version.
How to Lock Transitive Dependencies
The lockfile protects you if it exists and is up to date. But if it is ever regenerated, npm will re-resolve ranges and could pick a new — potentially malicious — version. To enforce a specific version for a transitive dependency regardless of what the lockfile contains or what the parent package requested, use the overrides field in package.json.
npm overrides (npm 8+)
overrides lets you forcibly replace any package in your entire dependency tree with a version of your choosing. It is respected by npm during resolution and is reflected in the lockfile when you run npm install.
Global override — all instances of a package get the same version:
"overrides": {
"axios": "1.7.9"
}
Use this when you want a blanket fix. The downside is that all consumers of axios — including those that requested a 0.x version — will receive the overridden version instead. In most cases axios 1.x is API-compatible with 0.x, but be aware of the change.
Package-specific override — only that package's dependency is affected:
"overrides": {
"@acme/dev-tools": {
"axios": "1.11.0"
},
"wait-on": {
"axios": "1.6.8"
}
}
This is the most surgical approach. Only the named parent packages have their axios pinned. Everything else resolves normally. The versions chosen (1.11.0 and 1.6.8) are exactly what was already in the lockfile — confirmed safe, no behavior change.
After adding overrides, run:
npm install --package-lock-only
This updates the lockfile to make the overrides explicit without touching node_modules. Commit both package.json and package-lock.json.
Yarn resolutions (Yarn 1 / Classic)
The equivalent in Yarn 1 is the resolutions field:
"resolutions": {
"axios": "1.7.9"
}
Checking What Is Currently Installed
To audit what version of a package is installed across your entire tree:
npm ls axios
This prints every resolved version and the parent chain that pulled it in, which helps you decide how targeted your override needs to be.
Summary
| Concept | Key takeaway |
|---|---|
| Supply chain attack | Attackers published malicious Axios versions by compromising a maintainer's credentials — no source change required |
| SemVer | MAJOR.MINOR.PATCH is a compatibility contract; ^ allows updates within a boundary |
^0.x.y vs ^1.x.y | ^0.21.1 only allows patch changes; ^1.3.2 allows any 1.x update — a much wider window |
| Transitive dependencies | Packages you never declared yourself but that live in your node_modules via the packages you did declare |
npm install | Deterministic when a lockfile exists; resolves fresh ranges when it does not |
overrides | Forces a specific version for any package in the tree, regardless of what any parent package requested, enforced at resolution time |
The lockfile is your first line of defense. overrides is your insurance policy for when the lockfile gets regenerated.