Back
Vulnerabilities28 Jul 2026 · 5 min read

Axios, three hours compromised: what the post-mortem teaches every JavaScript project

by detektd team

Close-up of a pile of old metal keys
Photo: Leeroy · CC0 · StockSnap

Four months on, the axios incident is still the year's reference case for the JavaScript supply chain. On March 31, 2026, an attacker took over the lead maintainer's npm account and published two malicious versions: axios@1.14.1 and axios@0.30.4. Axios is an HTTP client found across a considerable share of the ecosystem: more than 100 million weekly downloads, and around 174,000 packages depending on it.

0

npm packages depending on axios at the time of the compromise (March 2026)

The choice of the two version numbers is no accident. 1.14.1 targets the current line; 0.30.4 targets the old 0.x line, still used by many projects that never migrated. By publishing on both lines at once, the attacker maximized the number of projects whose version range would automatically accept the new release.

A phantom dependency

Axios's own code wasn't visibly changed. The poisoned versions simply added a dependency: plain-crypto-js, a typosquat of the legitimate crypto-js package. Axios never imported it; it served no functional purpose. Its only role was to be installed and, at install time, to deploy a remote access trojan (RAT) on macOS, Windows and Linux.

This "phantom dependency" technique is formidable because it sidesteps even the most careful code review: an audit of axios's source code would have found nothing. The only visible anomaly was one extra line in package.json: a new dependency, never used by the code.

Fake package published ~18h earlier
Maintainer's npm account taken over
axios released with the new dependency
npm install deploys the trojan

A prepared operation

Analyses published by Datadog, StepSecurity and Snyk agree on the timeline. The operation was staged over about 18 hours. The malicious dependency was published to npm well before the axios versions, so that by the time of the attack it wouldn't trigger alerts aimed at brand-new packages. The poisoned axios versions then stayed available for about three hours, from 00:21 to 03:25 UTC, before npm pulled them.

Three hours is short on a human scale and very long on a registry's. Every CI pipeline that resolved a range like ^1.14.0 without a lockfile during that window installed the poisoned version. So did every developer running a fresh install. And the timing was probably deliberate: the middle of the night in Europe, evening in the United States, when the fewest people are watching.

Why 0.30.4: the mechanics of semver ranges

The choice of version numbers becomes obvious once you look at how npm resolves ranges. The caret (^), used by default when you install a package, allows every update that doesn't change the first non-zero digit. For a 1.x version, that covers the whole 1 line. But for a 0.x version, the first non-zero digit is the second one: ^0.30.0 only accepts 0.30.x. To reach projects still on the 0.30 line, the attacker had to publish precisely a 0.30.x, which is what they did.

What each range accepted on March 31
^1.14.0  = >=1.14.0 <2.0.0    -> resolves to 1.14.1 (poisoned)
^0.30.0  = >=0.30.0 <0.31.0   -> resolves to 0.30.4 (poisoned)
~1.14.0  = >=1.14.0 <1.15.0   -> resolves to 1.14.1 (poisoned)
1.14.0   = exactly 1.14.0     -> unaffected

A range is only dangerous at the moment it's resolved. That's the whole difference between npm install and npm ci. The former resolves package.json ranges and may rewrite the lockfile if it's missing or out of sync. The latter installs exactly what the lockfile says, fails if it doesn't match package.json, and never writes it. A CI pipeline using npm ci with a committed lockfile couldn't have installed 1.14.1 during those three hours; a pipeline running npm install on a repo with no lockfile did.

The real entry point: the maintainer

According to the post-mortem the project published on GitHub, account access didn't come from an npm flaw. The lead maintainer was targeted by a tailored social engineering campaign, and their machine was compromised by malware that exposed their npm credentials. That matters: once a maintainer's machine is compromised, two-factor authentication doesn't protect much, since the attacker acts from an already-open session.

The pattern isn't new. In September 2025, the maintainer of chalk and debug was caught by an email impersonating npm, sent from the fake domain npmjs.help, which tricked them out of their username, password and two-factor code. In both cases, the target wasn't the code, but the person allowed to publish it.

Checking whether you were affected

The check is simple: look, in every project and every lockfile, for the two poisoned versions or the phantom dependency. The npm ls command shows the tree of installed dependencies, transitive ones included:

Look for the poisoned versions in a project
npm ls axios plain-crypto-js --all

# or straight in the lockfile
grep -n "plain-crypto-js" package-lock.json

If the answer is yes

Upgrading axios isn't enough: a remote access trojan may have been installed on the machine. Treat the machine or runner as compromised, reinstall it, and rotate every secret that was reachable from it.

The lesson: don't install what just came out

The poisoned versions were caught and pulled within three hours. A minimum delay before accepting any new version, even a single day, would have been enough to never install them. pnpm supports it natively:

pnpm-workspace.yaml: refuse versions younger than 24h
# in minutes: a version must be at least a day old to be installed
minimumReleaseAge: 1440
  • Commit the lockfile: a ^ range then only resolves during a deliberate upgrade, not on every build.
  • Watch for new dependencies appearing in an upgrade: that's precisely what the poisoned version added.
  • Don't auto-merge upgrades proposed by Dependabot or Renovate within hours of a release.

Spotting a phantom dependency before installing it is possible, as long as you look at what actually changes between two versions. The npm diff command compares two published versions of a package, file by file, without installing anything. Applied to package.json alone, it immediately surfaces an added dependency, the exact signal axios@1.14.1 would have given:

Compare two versions before accepting an upgrade
npm diff --diff=<package>@<current> --diff=<package>@<candidate> package.json

# a new entry under "dependencies" that the code never imports is a red flag

On the maintainers' side

For those who publish packages, the lesson is to shrink as much as possible what a compromised machine can do. npm lets you require two-factor authentication and disallow tokens for publishing a package. And trusted publishing, which publishes from CI with short-lived credentials, removes the ability to publish from a personal computer entirely, provided the repository and its workflows are then protected in turn.