Blog / Security news

Axios npm compromise: how the trust chain broke

The Axios npm compromise wasn’t a code bug. Follow the six links of the trust chain, the control that breaks each one, and what npm 12 changes.

Completed doctoral studies in Information SecurityUniversity defense passed · Final dissertation defense pending

The code was fine. The publisher wasn’t.

Two registry records, one version apart. Axios 1.14.0 was published by GitHub Actions through a trusted publisher and carries provenance. Axios 1.14.1, which appeared at 00:21 UTC on 31 March 2026, was published by hand with none of that. That difference is the whole story of the Axios npm compromise, and it was sitting in the registry metadata. [2]

The facts, briefly. Axios 1.14.1 and 0.30.4, published 39 minutes apart, added one new dependency, plain-crypto-js@4.2.1, whose postinstall script dropped a remote access trojan on any machine that ran the install. npm removed both versions by 03:15 UTC. [1][2] Google attributes the operation to UNC1069, a North Korea-nexus group, with high confidence, and Microsoft tracks the actor as Sapphire Sleet. [3][4]

I call this a trust chain attack. A software supply chain attack changes the code you run without changing a line you reviewed, by compromising the channel that delivers a dependency. Nobody exploited a bug in the axios source. Every link was a trust decision somebody made, and that’s the useful way to read the incident. I haven’t reproduced any of this myself; the analysis rests on the maintainers’ post-mortem, vendor reports and the registry records as they stand on 7 October 2026.

axios@1.14.0   published by CI
  _npmUser           GitHub Actions
  trustedPublisher   github
  gitHead            46bee3dea75ef53a8eae49f3b7487e6341de6074
  dist.attestations  SLSA provenance v1

axios@1.14.1   2026-03-31T00:21:58Z, published by hand
  _npmUser           the lead maintainer's account
  trustedPublisher   absent
  gitHead            absent
  GitHub commit/tag  none; the release exists only on npm

The chain started on a laptop and ended on a laptop

The first link wasn’t npm or GitHub. According to the maintainers’ post-mortem, the attacker reached the lead maintainer’s PC through a targeted social engineering campaign and RAT malware, and that gave them the npm account credentials used to publish. [1] A RAT on a developer machine produced the token. The token produced a RAT on every developer machine and CI runner that installed the poisoned version. Same malware class at both ends.

The maintainers were blunt about it. Publishing directly from a personal account was a risk that could have been avoided, and the OIDC flow and immutable releases they’re now adopting should have been in place before this happened. [1] I respect that sentence. It names the real lesson, and the lesson isn’t about axios. It’s about where publish authority lives.

I read the incident as six links, shown below. At each one a control could have broken the chain, and each one has a different owner. Some belong to the publisher. Most belong to you. I’ll walk them in order.

1  maintainer laptop    social engineering + RAT         -> npm credentials
2  registry gate        direct token publish accepted    -> axios 1.14.1, 0.30.4
3  decoy dependency     plain-crypto-js 4.2.0 -> 4.2.1   -> new line in package.json
4  your resolver        fresh install, no cooldown       -> poisoned tarball fetched
5  install script       postinstall: node setup.js       -> dropper runs
6  runner or laptop     reachable secrets, open egress   -> RAT, then whatever it can reach

Why didn’t OIDC trusted publishing stop a stolen token?

Because it wasn’t the only door. Trusted publishing lets the registry accept a publish only from a named CI workflow, using a short-lived OIDC credential instead of a stored secret. Axios 1.x releases already went through it, which is why 1.14.0 shows a trusted publisher and provenance. [2][6] The malicious 1.14.1 was published straight from an account, with no trusted publisher, no gitHead and no matching commit or tag in the repository. [2]

A good publish path doesn’t remove the bad one. As long as the package still accepts tokens, whoever holds one can skip the workflow entirely. The control that closes it is a setting on the package, Require two-factor authentication and disallow tokens, which npm’s documentation recommends once trusted publishers are configured, and which leaves those trusted publishers working normally. [6] StepSecurity reads the missing metadata as a stolen long-lived classic token; the maintainers say only that the attacker obtained the account credentials. [1][2] Either way, the publish never touched the workflow.

My opinion: switching on OIDC without switching tokens off is half a control, and teams tick the box after the first half. If you publish packages, check both halves today. If you consume them, registry metadata is a signal you can read. A version that used to come from a trusted publisher and suddenly doesn’t deserves a hold. npm is tightening the other side too: from January 2027, granular tokens that bypass two-factor authentication lose the ability to publish directly. [7]

A dependency that nothing imports is a finding

The attacker prepared the ground a day early. plain-crypto-js@4.2.0 went up at 05:57 UTC on 30 March as a clean decoy: a copy of the legitimate crypto-js code with no install hook. Its only job was to give the package a publishing history so it wouldn’t look brand new. The malicious 4.2.1 followed at 23:59 UTC, 22 minutes before axios 1.14.1. [2]

Now the tell. StepSecurity searched all 86 files in axios@1.14.1 and found that plain-crypto-js is never imported or required anywhere. [2] The code axios shipped had no use for it. The release also dropped the husky prepare script, which fits a manual publish that skipped the normal release tooling. [2] Put plainly, a patch release added a runtime dependency the package never uses. That’s a line a human reviewer stops on.

It’s the check I’d automate first. On every upgrade, diff the manifest between the old and new version. A new dependency in a patch bump goes to a person. Then ask three questions about it: how old is the package, who maintains it, and does the parent actually use it? The commands below answer all three in under a minute. Your update bot doesn’t ask them for you.

# what changed in the manifest between two versions
npm diff --diff=<pkg>@<old> --diff=<pkg>@<new> package.json

# does the package actually use the new dependency?
npm pack <pkg>@<new> && tar -xzf <pkg>-<new>.tgz && grep -R "<new-dep>" package/

# how old is the new dependency, and who maintains it?
npm view <new-dep> time maintainers --json

Why did npm install alone hand over the machine?

Because npm runs code on install. A lifecycle script is a command a package declares in its package.json and the package manager executes automatically, and plain-crypto-js@4.2.1 declared postinstall: node setup.js. [3] That runs before your application imports anything, which is why a project that never touched the library at runtime was still exposed. StepSecurity saw the first call to the attacker’s server within two seconds of npm install. [2]

The obfuscation in setup.js is thinner than it looks. Strings are reversed, Base64-decoded and XORed against the key OrDeR_7077 and the constant 333. [5] StepSecurity’s deobfuscation shows the key passes through Number(), so its letters become NaN, XOR with zero changes nothing, and the effective key is 0,0,0,0,0,0,7,0,7,7. [2] That’s a trick against string scanners, not cryptography. The lesson: static analysis of install scripts is a useful tripwire and a weak boundary.

Then the dropper branches on the platform and asks the attacker’s server for a second stage. The POST body tells the server which one to send: product0 for macOS, product1 for Windows, product2 for Linux. [2] The table shows where each one lands. All three variants beacon every 60 seconds, Base64-encoded JSON, with the User-Agent of Internet Explorer 8 on Windows XP. [3] Because the RAT is fetched live, nothing in the tarball is the malware proper. The package is a downloader. That makes outbound traffic the control that matters, and an IE8 User-Agent from a build runner the cheapest alert you’ll ever write. [3]

platform  stage two lands in                   started by
win32     %TEMP%\6202033.ps1                   VBScript; powershell.exe copied to %PROGRAMDATA%\wt.exe
darwin    /Library/Caches/com.apple.act.mond   Mach-O binary, chmod +x, zsh in the background
linux     /tmp/ld.py                           nohup python3

all       POST http://sfrclak[.]com:8000/6202033
          body: packages.npm.org/product0 (macOS), product1 (Windows), product2 (Linux)
          beacon: Base64 JSON every 60 seconds
          User-Agent: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
          win32 persistence: HKCU Run key "MicrosoftUpdate" and %PROGRAMDATA%\system.bat

Why did the folder look clean afterwards?

Because the dropper tidied up. After it ran, setup.js deleted itself and the malicious package.json, then renamed a pre-staged package.md to package.json. [2] That replacement manifest declares version 4.2.0 with no install hook. An engineer who opens node_modules/plain-crypto-js afterwards sees a harmless 4.2.0, even though 4.2.1 is what ran. [2] The decoy seeded a day earlier pays off a second time.

That’s why I tell teams to check the lockfile, not node_modules. A lockfile entry for plain-crypto-js, at 4.2.0 or 4.2.1, means your install graph included the attack, and Google’s guidance is to assume the host is compromised from that point. [3] What to preserve and how to prove recovery is covered in my earlier Axios post on build evidence. Here I’ll stay on the mechanism.

What did the RAT do? Google’s analysis of the payload, which it calls WAVESHAPER.V2, lists system reconnaissance, directory listing, arbitrary command execution and binary injection. [3] That’s access, not theft. What an operator does with access to a developer laptop or a CI runner is read the tokens, keys and .env files sitting there, and OpenAI shows the stakes: a GitHub Actions workflow in its macOS app-signing process ran axios 1.14.1 with a signing certificate and notarization material in reach. OpenAI found no evidence that user data was accessed or its software altered, and rotated the certificate as a precaution. [12]

Who actually needs to worry about this one?

Less of your estate than the headlines suggested, and a more specific part of it. The shipped application isn’t the victim. Nothing imports plain-crypto-js, so a front-end bundle built from axios 1.14.1 had no reason to contain the payload; the machine that ran the install is the victim. [2] The window was short too: 00:21 UTC to the 03:15 UTC removal, under three hours. [1]

For you to be affected, three things had to be true. A fresh install resolved axios 1.14.1 or 0.30.4 in that window, which means an install without a matching lockfile or an update bot merging within hours. Install scripts were allowed to run, which was the default. And the machine held something worth reaching. During the incident, Microsoft’s guidance was to pin exact versions and pause automated dependency bots. [4] If you can answer those three questions for every runner and laptop, you know your exposure. If you can’t, that’s the real finding.

Eight checks to run before your next npm install

Here’s the version of this post you can act on. Start with your own repositories, then your CI images, then the packages you publish. The list below is ordered by how fast it pays back.

When I review a codebase, the dependency manifest, the lockfile and the release configuration are in scope, because the trust boundaries that matter often sit there and not in the source you wrote. That’s the part of this chain a normal penetration test usually doesn’t reach. If you want an attacker’s eyes on how your product trusts its dependencies and its release path, tell me what you ship. If you think you were hit, preserve evidence before you rebuild and bring in a dedicated incident responder. That’s a different job from mine.

1  lockfile    grep -nE "plain-crypto-js" package-lock.json     # 4.2.0 or 4.2.1 means act
2  CI install  npm ci, never npm install; no bot merges within hours of a release
3  scripts     npm 12: review allowScripts | older npm: --ignore-scripts, allow by name | pnpm 10+: allowBuilds
4  cooldown    npm config set min-release-age 3 | pnpm: minimumReleaseAge
5  trust       pnpm: trustPolicy: no-downgrade
6  publishing  your packages: trusted publisher on, "disallow tokens" on, no publish token on a laptop
7  egress      build runners: default-deny outbound; alert on node spawning curl, powershell or python3
8  secrets     the install job holds no deploy, signing or cloud credentials

Sources

Need this checked in your own product?

Security code review
All articles

08Contact

Shipping something that handles user data?

Send me a short brief. I reply within three days with questions or a proposal outline, and the price is agreed in writing before work starts.