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 npmThe 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 reachWhy 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 --jsonWhy 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.batWhy 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.
Which control breaks which link?
One per link, and the table below is the whole model. Breaking a single link is enough to stop a given attack. You don’t get to choose which link the next attacker leaves intact, so cover more than one.
Link five changed this year. npm 12, generally available since 8 July 2026, no longer runs dependency preinstall, install and postinstall scripts unless the project allows them, recorded in an allowScripts list you commit. [7][8] On npm 12, the plain-crypto-js hook wouldn’t have run without an explicit allow. But check what your CI actually uses: the node:24-alpine image ships npm 11.19.0 as of 7 October 2026, and npm 11 still executes dependency scripts by default. pnpm has blocked them by default since version 10, and even that setting needed verifying: before 10.26.0, git-hosted dependencies could still run code during install, a high-severity bypass. [9][10] A setting becomes a boundary once you’ve watched it block something.
Link four has a cheap control too. npm 11.10.0 added min-release-age, a cooldown in days, in February 2026. pnpm’s minimumReleaseAge is in minutes and defaults to one day in pnpm 11. [10][11] The poisoned axios versions lived under three hours, so even a one-day cooldown outlasts them. A cooldown is a bet that someone notices within the window, and it does nothing against a patient attacker. Pair it with pnpm’s trustPolicy: no-downgrade, which refuses a version whose trust level dropped compared with earlier releases. [10] That’s built for exactly the shape of 1.14.1, but I haven’t replayed this attack against it, because 1.14.1 is gone from the registry. Test it on a package you control.
link control owner
1 maintainer laptop no publish credential on a workstation publisher
2 registry gate trusted publisher + "disallow tokens" publisher
3 decoy dependency diff the manifest; question new deps in patches you
4 fresh resolution npm ci + lockfile; min-release-age / pnpm cooldown you
5 install script npm 12 allowScripts; pnpm 10+; --ignore-scripts you
6 reachable secrets short-lived creds, split jobs, egress allow-list you
+ trust downgrade pnpm trustPolicy: no-downgrade youEight 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 credentialsSources
- [1] Axios maintainers: incident post-mortem
- [2] StepSecurity: axios compromised on npm, technical analysis
- [3] Google Threat Intelligence: North Korea-nexus actor targets axios
- [4] Microsoft: mitigating the Axios npm supply chain compromise
- [5] JFrog Security Research: plain-crypto-js malware analysis
- [6] npm documentation: trusted publishers
- [7] GitHub changelog: npm install-time security and GAT bypass-2FA deprecation
- [8] npm documentation: install scripts in npm 12
- [9] pnpm security advisory GHSA-379q-355j-w6rj: lifecycle script bypass
- [10] pnpm documentation: supply chain security settings
- [11] npm CLI release v11.10.0: min-release-age
- [12] OpenAI: axios developer tool compromise
Need this checked in your own product?
Security code review