- Published on
The 3-Hour axios Account Takeover — Provenance Was On, and Nobody Checked It
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — March 31, 2026, 00:21 UTC
- The Receipts the Registry Left Behind
- The Entry Point — Not a Token, a Person's PC
- Trusted Publishing Was On
- Where One Vendor's Explanation Doesn't Match the Timeline
- What Actually Stopped This Was a Person
- What Provenance Actually Proves
- Actually Running npm audit signatures
- The Control That Would Actually Have Worked — Waiting
- Nobody Knows the Blast Radius
- So What Should You Actually Do
- Closing
- References
Introduction — March 31, 2026, 00:21 UTC
Supply-chain security posts tend to come in two flavors: news that an incident happened, or generic advice to "adopt an SBOM and verify signatures." Neither is much help. The first never tells you which control failed and why; the second can't explain why someone who had already turned that control on still got hit.
The axios incident on March 31, 2026 is squarely the second case. axios was already using npm's trusted publishing. The last legitimate release carried SLSA provenance. The maintainer's account had 2FA turned on. And it still got compromised.
What sets this piece apart is that I pulled most of the timestamps here directly from the npm registry API myself, and you can verify them the same way right now. The incident is over, but the receipts are still sitting in the registry.
The Receipts the Registry Left Behind
Package metadata on the npm registry carries a time map. It has one interesting property: even when a version gets unpublished, its time entry stays. So when the deleted malicious version was first published is still public knowledge.
Try it yourself.
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
t = d['time']
for v in ['1.14.0', '1.14.1', '0.30.3', '0.30.4']:
print(v, t.get(v), '| currently exists:', v in d['versions'])
"
Here's what I got when I ran this on July 16, 2026:
1.14.0 2026-03-27T19:01:40.915Z | currently exists: True
1.14.1 2026-03-31T00:21:58.168Z | currently exists: False
0.30.3 2026-02-18T17:19:20.081Z | currently exists: True
0.30.4 2026-03-31T01:00:57.285Z | currently exists: False
1.14.1 and 0.30.4 have a recorded publish time but don't exist now. That's the fingerprint of an unpublish, and both timestamps line up to the second with the timeline in the official post-mortem written by maintainer Jason Saayman.
The same holds for the injected dependency.
curl -s https://registry.npmjs.org/plain-crypto-js | python3 -m json.tool | head -20
plain-crypto-js's time map still shows 4.2.0 at 2026-03-30T05:57:32.867Z and 4.2.1 at 2026-03-30T23:59:12.278Z, and its current dist-tags.latest is 0.0.1-security.0. The repository field reads npm/security-holder — a placeholder npm parks on names after it removes a malicious package. That placeholder went up at 2026-03-31T03:25:11.636Z.
Putting the sequence together: the attacker first published an innocuous plain-crypto-js@4.2.0 at 05:57 on March 30 to stake out the name, followed it with a malicious postinstall in 4.2.1 at 23:59 that same night, and then, less than an hour later at 00:21 on March 31, published axios@1.14.1 with that package wired in as a dependency. The decoy went in first; the real target came second.
The Entry Point — Not a Token, a Person's PC
According to the post-mortem, this started not with the registry but with a person. Over roughly two weeks of targeted social engineering, the attacker planted a RAT (remote access trojan) on the lead maintainer's PC and harvested the npm account credentials from there.
What matters here is that 2FA was turned on (per Datadog Security Labs' analysis). Of course it was — once an attacker is sitting inside the maintainer's own machine, 2FA is just a formality to click through. 2FA is a control against "someone else logging in from elsewhere," not against "someone who has become your computer."
The payload was a cross-platform RAT targeting macOS, Windows, and Linux, with C2 at sfrclak[.]com (port 8000). The post-mortem advises anyone who might be affected to grep their lockfiles for axios@1.14.1, axios@0.30.4, and plain-crypto-js, treat any hit as a compromised machine, and rotate every credential on it — and if a CI runner is hit, rotate every secret injected into that build.
Trusted Publishing Was On
Now for the most interesting part of this incident. axios was already using trusted publishing. Pulling the per-version publisher and provenance status from the registry makes this immediately visible.
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
for v in ['1.13.0', '1.14.0', '1.18.1', '0.30.3']:
ver = d['versions'][v]
att = ver['dist'].get('attestations')
print(v, '| publisher:', ver['_npmUser']['name'], '| provenance:', 'yes' if att else 'no')
"
The result:
1.13.0 | publisher: jasonsaayman | provenance: yes
1.14.0 | publisher: GitHub Actions | provenance: yes
1.18.1 | publisher: GitHub Actions | provenance: yes
0.30.3 | publisher: jasonsaayman | provenance: no
Two things stand out here.
First, the switch to trusted publishing is visible right in the record, between 1.13.0 and 1.14.0. The publisher name flips from a personal account to GitHub Actions. That means the last legitimate release, 1.14.0, went out four days before the attack, on March 27, published by GitHub Actions over OIDC, with provenance attached automatically.
Second, the legacy 0.x line had no provenance at all. It was still going out manually from a personal account. It may not be a coincidence that the attacker targeted 1.14.1 and 0.30.4 together — the 0.x line was a soft target to begin with, with no signal to even miss.
That reframes the question: trusted publishing was turned on, so why did a publish still go out from a personal account?
The answer is right there in npm's own documentation. When you set up trusted publishing, npm accepts publishes from the approved workflow, but — to quote the docs directly — it does so "in addition to traditional authentication methods like npm tokens or manual publishing." In other words, trusted publishing is, by default, an additional path, not an exclusive one. To close off the token and manual-publish paths, you have to separately select "Require two-factor authentication and disallow tokens" in the package settings.
So, to be precise, trusted publishing wasn't breached. It wasn't cryptographically bypassed either. An unlocked side door was simply still there, and the attacker walked through it. That's what the maintainer meant by writing "Proper adoption of OIDC flow for publishing" in the post-mortem's "what's changing going forward" table. The emphasis lands on proper — not turning it on, but making it the only path.
Orca Security got this structural point exactly right, too. In their words: "Trusted Publishing only works if it's the sole publishing path." That's correct, and npm's own documentation backs it up.
Where One Vendor's Explanation Doesn't Match the Timeline
That said, the same Orca post also states: "The likely entry point was a long-lived classic npm access token, which can sidestep 2FA requirements entirely." That's a hedge — a hypothesis that a lingering classic token was likely the entry point. It's a plausible-sounding story: a legacy token bypassing modern controls through a back door is a clean narrative, and one the security industry tends to like.
The problem is the date. On December 9, 2025, GitHub stated flatly in its changelog: "We've permanently revoked all existing npm classic tokens. They can no longer authenticate, be recreated, or be recovered." Every classic token was permanently revoked, with no path to authenticate, recreate, or recover it. The same announcement switched npm login to issue 2-hour session tokens instead of long-lived ones.
The axios attack happened on March 31, 2026 — roughly three and a half months later. At that point, classic tokens could not authenticate at all. So Orca's hypothesis is hard to sustain. The maintainer's own post-mortem describes a far simpler path — social engineering planted a RAT on a PC, and the credentials were harvested from there.
To be fair, Orca hedged with "likely," and their structural conclusion ("it needs to be the sole path") was correct. I'm not saying they lied. But this is a common failure mode in incident analysis — fitting an incident to a known failure pattern can land on a conclusion that happens to be right while sending the response in the wrong direction. Believing this hypothesis produces an action item like "audit for lingering classic tokens," which is a problem npm already solved globally four months earlier — while the actual problems, "the maintainer's workstation was compromised" and "the manual-publish path was left open," go untouched.
The lesson is simple. Vendor blog posts are useful for IOCs and technical analysis, but check any narrative about the entry point against the primary post-mortem and the registry timeline. In this case, that cross-check took five minutes.
What Actually Stopped This Was a Person
So what actually stopped it? Not provenance. Not trusted publishing, not 2FA, not any scanner.
The post-mortem's own words say it most precisely: "There was no automated way to detect an unauthorized publish. Detection depended entirely on the community noticing."
Here's what actually happened. PR #10591, confirmed via the GitHub API, was opened by axios collaborator DigitalBrainJS at 2026-03-31T01:38:47Z and merged at 01:42:15Z. Its title is plain: chore(ci): add deprecate action;. That's 77 minutes after the malicious publish at 00:21. He deprecated the compromised versions, alerted the community, and contacted npm directly. The malicious versions came down from the registry at 03:15.
Two details here are easy to miss.
The attacker was deleting reports. Per the post-mortem, community members started filing issues reporting the compromise around 01:00, and the attacker, still in control of the hijacked account, deleted those issues. Account takeover doesn't just hand over publish rights — it hands over moderation rights too. The attacker could ship the malicious release and erase the reports about it at the same time.
The responder had lower privileges than the attacker. In the acknowledgments, the maintainer wrote that DigitalBrainJS moved quickly "while the compromised account held higher privileges than his own" and got npm to act. It's a scenario almost nobody plans for when drafting an incident response plan — what can the rest of the team do when the account with the highest privileges is the adversary?
A three-hour window sounds short, but that shortness wasn't the payoff of any control — it was the payoff of one collaborator who happened to be awake. Had he been asleep that night, the window would have been several times longer. Detection that depends on a person doesn't scale.
What Provenance Actually Proves
This is worth pinning down precisely, because people tend to misread provenance as a "safety certificate."
Opening 1.14.0's attestation bundle directly shows exactly what's in it.
curl -s "https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0" | python3 -c "
import sys, json, base64
d = json.load(sys.stdin)
for a in d['attestations']:
if 'slsa' not in a['predicateType']: continue
p = json.loads(base64.b64decode(a['bundle']['dsseEnvelope']['payload']))
print(json.dumps(p['predicate']['buildDefinition']['externalParameters'], indent=2))
"
Here's what comes out:
{
"workflow": {
"ref": "refs/tags/v1.14.0",
"repository": "https://github.com/axios/axios",
"path": ".github/workflows/publish.yml"
}
}
In other words, what provenance signs and states is this: this tarball was built from the v1.14.0 tag in the axios/axios repository, using the publish.yml workflow, on a GitHub-hosted runner. Provenance and build path.
That's all it is. npm's own documentation states it directly: "When a package in the npm registry has established provenance, it does not guarantee the package has no malicious code." Having provenance is not a guarantee of absence of malicious code.
Why this matters here: this attacker had control of the maintainer's PC. In that state, it's plausible they could have grabbed GitHub credentials too. Had the attacker pushed a commit to the repository and tagged it, instead of publishing directly to npm, CI would have built it and produced a malicious package with perfectly valid provenance. The signature would be genuine, the origin would be genuine — only the code would be malicious. Provenance would not have caught that. It was never built to.
So turning trusted publishing on as the exclusive path would have blocked this specific attack — but that wouldn't have eliminated the attack, only moved it to a more expensive path. The route of repo push → review → tag → CI has branch protection and review sitting in front of it, and above all, it leaves a commit as a trace. That's the honest value of a control: not making something impossible, but making it expensive and loud.
Actually Running npm audit signatures
But there's a more painful angle to this incident. The signal was sitting right there in the registry.
1.14.0 had provenance, and its publisher was GitHub Actions. 1.14.1 had no provenance, and its publisher was a personal account. That difference was exposed on the public API the instant it was published. If a consumer had simply enforced "axios must have provenance originating from the axios/axios repository," 1.14.1 would have been rejected automatically.
The problem is that nobody enforces that, because it isn't the default.
I checked this myself. I installed a package with provenance alongside one without and ran npm audit signatures.
npm init -y
npm i axios@1.14.0 left-pad@1.3.0
npm audit signatures
Here's the result:
audited 24 packages in 0s
24 packages have verified registry signatures
1 package has a verified attestation
The exit code was 0. It passed.
Only 1 out of 24 packages has a verified attestation, and it still passes. In other words, npm audit signatures only verifies what's present, and doesn't flag what's absent. Missing provenance is not treated as an error. That means if the attacker had published 1.14.1 without any provenance, this command would have emitted the exact same 0.
That's the central irony of this incident. The ecosystem succeeded at producing the signal, and failed at consuming it. Provenance is a publisher-side artifact, and it's worth zero unless a consumer enforces verification. The badge looks nice, but CI never asks it a single question.
There's a chicken-and-egg problem underneath this. Provenance coverage is low, so "fail if no provenance" can't become the default; and because nobody turns it on, provenance's value never rises. So the realistic form of enforcement isn't a global policy — it's pinpoint verification on the handful of high-risk packages you already know about. axios, build tools, CI actions — start with the ones you can count on one hand.
The Control That Would Actually Have Worked — Waiting
So as a consumer, what control could have actually stopped this attack? Not a signature. Time.
pnpm has a minimumReleaseAge setting. Per the official docs, it delays installation of newly published versions to reduce the risk of installing a compromised package. The value is in minutes, and it applies to every dependency, including transitive ones. And the sentence the docs cite as justification lines up exactly with this incident: "In most cases, malicious releases are discovered and removed from the registry within an hour."
The default matters here. Starting with pnpm v11, minimumReleaseAge defaults to 1440 minutes — 24 hours (it was 0 before v11).
The window during which axios's malicious version was live was roughly three hours. A 24-hour wait covers that entire window. Anyone installing under pnpm v11's default at the time of the incident would never have encountered 1.14.1 at all. No signatures, no scanner, no threat intel required. Just a day of waiting.
That's a somewhat deflating conclusion. Set against everything the ecosystem has poured into sigstore and SLSA over the past three years, "just wait a day" looks almost too modest. But confined to this specific incident, it's what actually worked, and provenance wasn't. As long as the dominant shape of supply-chain attacks is "publish in a short window, get discovered, come down," waiting is asymmetrically powerful — it targets directly the one asset the attacker actually has: that window.
There are trade-offs here, and they deserve to be stated honestly.
- Security patches arrive late. Even a genuinely urgent CVE fix waits 24 hours. That's why there's a
minimumReleaseAgeExcludemechanism to carve out exceptions for specific dependencies. - It doesn't stop long-dormant attacks. If an attacker publishes a malicious version and simply waits a week, the delay is powerless. Waiting only works against "attacks that get discovered quickly." The later discovery comes, the less this is worth.
- It's evasion, not detection. Waiting doesn't tell you anything. It just lets everyone else step on the mine first. Whether that's a fair strategy at the ecosystem level is a separate question — if everyone waits 24 hours, nobody discovers anything first. This is the kind of control that works best precisely when other people aren't using it.
Nobody Knows the Blast Radius
One last point, on the numbers. Most pieces covering this incident cite "roughly 100 million weekly downloads" and "over 174,000 dependent packages." Both Orca and Datadog write "roughly 100 million weekly downloads and over 174,000 dependent packages."
I re-measured this myself against npm's official downloads API.
curl -s "https://api.npmjs.org/downloads/point/last-week/axios"
{"downloads":85473581,"start":"2026-07-09","end":"2026-07-15","package":"axios"}
From July 9 to July 15, 2026, the measured figure is 85,473,581. "Roughly 100 million" is a generous rounding, and either way, it's a present-day figure, not a figure at the time of the incident. The gap isn't huge, but it's worth flagging that the source is a 30-second check away before repeating a cited number as-is.
More important is this: how many times the malicious version was actually installed is not publicly knowable. Looking at npm's per-version downloads API, 1.14.1 and 0.30.4 don't even appear in the list.
curl -s "https://api.npmjs.org/versions/axios/last-week" | python3 -c "
import sys, json
dl = json.load(sys.stdin)['downloads']
for v in ['1.14.0', '1.14.1', '0.30.4']:
print(v, dl.get(v, 'none'))
"
1.14.0 1735295
1.14.1 none
0.30.4 none
Download counts for unpublished versions are not exposed. So any statement along the lines of "100 million people were at risk" is entirely a measure of potential exposure, not actual harm. Even for a package with 85 million weekly downloads, how many of those, within a three-hour window, ran a fresh install and how many of those tracked latest are separate questions entirely. If you were pinned in a lockfile and didn't run a fresh install during that three-hour window, you were fine — the post-mortem says exactly that.
Orca's framing, noting that the package was "live for under three hours" to qualify that actual exposure was limited, is a fair way to put it. But the number that survives in headlines is always the 100 million. Security vendors' tallies tend to lean toward the maximum potential exposure, and while that isn't always false, it's worth remembering it's always a ceiling. The honest answer here is: we don't know. The registry doesn't publish that data.
So What Should You Actually Do
The actions that actually follow from this incident depend on your role.
If you publish packages
- Don't stop at turning trusted publishing on. You have to close tokens and manual publishing in the package settings for it to become the exclusive path. Trusted publishing that's merely turned on leaves a side door, as axios proved.
- Don't forget your legacy release lines. axios's 0.x line was going out without provenance from a personal account. Attackers pick the least-guarded line, not the best-guarded one.
- Build an alert path for unauthorized publishes. Detection here was entirely down to human luck.
If you consume packages
- Turn on
minimumReleaseAge(already defaulted to 24 hours as of pnpm v11). For this specific incident, this is the only control that would actually have worked. - Pin your lockfile, and don't run fresh installs at arbitrary times. People who were pinned were simply safe.
- Force verification of provenance origin for a handful of high-risk packages.
npm audit signaturesdoesn't flag missing provenance, so it can't be trusted alone. - Consider blocking install scripts. This payload ran via
postinstall.
Either way
Most registry-level controls go dark in front of a maintainer workstation with a RAT planted on it. The root cause of this incident wasn't npm — it was two weeks of social engineering. And that isn't a problem signatures solve.
Closing
To sum up: axios had trusted publishing turned on, was issuing provenance, was using 2FA — and still shipped a cross-platform RAT for three hours. Not because trusted publishing was breached, but because it wasn't exclusive; not because provenance failed, but because nobody enforced it. And what stopped it wasn't any control — it was one collaborator who happened to be awake that night.
If there's one thing to take from this piece, it's this: a control only earns its value once it's made exclusive and enforced, not merely turned on. A control that's merely on will pass an audit checklist. It won't stop an attacker outside the dashboard.
One more thing. Most of the facts in this piece came not from vendor blog posts but from curl and the primary post-mortem, and along the way I found that one widely cited explanation of the entry point doesn't match the registry timeline. When you read an incident, start with primary sources. The registry, mostly, doesn't lie.
References
- Post Mortem: axios npm supply chain compromise — Jason Saayman (axios/axios#10636) — the maintainer's own primary post-mortem
- PR #10591 — DigitalBrainJS's commit deprecating the compromised versions
- Compromised axios npm package delivers cross-platform RAT — Datadog Security Labs — technical analysis and IOCs (vendor's own analysis)
- Supply Chain Attack on Axios Delivers Cross-Platform RAT via Compromised npm Account — Orca Security — structural point is sound, but the classic-token hypothesis doesn't match the timeline (vendor-measured figures)
- npm Trusted Publishers official docs — the "in addition to" language and the token-blocking setting
- Generating provenance statements — npm official docs — states directly that provenance does not guarantee the absence of malicious code
- npm classic tokens revoked, session-based auth — GitHub Changelog (2025-12-09)
- pnpm settings — minimumReleaseAge
- Why npm supply-chain attacks won't go away (related post)