The tells were in the registry, the build logs and the bundle.
In the registry
- A burst of new patch versions of dormant packages:
simple-swizzleandbackslashlast changed in 2016,is-arrayishanderror-exin 2018. - New versions with no matching commit or tag in the repository. That is what the first GitHub reporter noticed:
src/index.jslooked “like a cryptostealer”.1
In builds and bundles
- Server or CI builds that suddenly threw errors because
fetchwasn't defined: the payload assumed browser globals. researchers16 - A distinctive obfuscated identifier in shipped JavaScript; Sindre Sorhus posted a search for it on the chalk issue.4
In the browser
- A wallet asked to sign a transfer or an
approveto an address that differs from the one you meant but looks alike at a glance. - Transfers to the attacker's Ethereum address, which Aikido and SEAL published for defenders.14
One email, two registries' worth of trust, and a user's wallet.
No code in these projects had a bug, and no build pipeline was broken into. The attacker needed one thing: the login of a person who could publish. After that, every step was a feature working as designed: semver ranges took the new patch, bundlers copied it into front ends, and browsers ran it for users.
It is also why the clean-up was unusual. Most npm malware runs when you install it. This code did nothing on the developer's machine and went through the build into the JavaScript that websites serve. Dashed boxes are the attacker's.
- Second factor (2FA)Something besides the password that proves a login, such as a one-time code from an app or a passkey.
- Crypto-clipperMalware that replaces a cryptocurrency address or transaction recipient with the attacker's own.
5 Sep · domain registered; 8 Sep · 00:50:20 UTC
A look-alike domain sends a deadline.
npmjs.helpwas registered on 5 September. Three days later, at 00:50:20 UTC by the email's ownDate:header (02:50 in Junon's time zone), it sent “Two-Factor Authentication Update Required” fromsupport@npmjs.help.3It used npm's branding, a plausible reason (2FA credentials more than 12 months old) and a deadline: accounts would be “temporarily locked starting September 10, 2025”. The button said “Update 2FA Now”. sources disagree Sygnia puts the email at 13:00 UTC; the published header says 00:50, and Socket's 2:50 AM local time matches it.18
- from
- support@npmjs.help
- domain age
- 3 days
- subject
- Two-Factor Authentication Update Required
- deadline
- locked from 10 Sep 2025
- sent
- 00:50:20 UTC (header)
8 Sep · between 00:50 and 13:12 UTC
A copy of npm's login passes everything to the real one.
Junon followed the link and entered his credentials: “It was a 2FA reset email that looked shockingly authentic.” When he did so exactly, and which second factor he typed, is not public.1
The DuckDB maintainers, phished by the same lure the next day, describe the page: a “pixel-perfect copy” that “forwarded all actions to the actual npm website”. Their maintainer entered username, password and 2FA code, and the attacker's side then added a new API token and published with it. A one-time code is phishable for exactly this reason: a relay can pass it on while it is still valid.8
- page
- copy of npmjs.com
- behaviour
- forwards to the real site
- typed (DuckDB)
- username, password, 2FA code
- one-time code
- relayed while valid
The account is taken over, and its owner locked out.
The attacker changed the account's email address so Junon couldn't recover it. It was his personal account: “Repositories are not affected.”
The choice of packages looked deliberate. In Junon's words: “This appears targeted, or at least with a filter for high downloads. Many other packages on my account are untouched.”1
- account
- qix (personal)
- changed by the attacker
- owner
- locked out
- GitHub repos
- not affected
8 Sep · 13:12:10 → 13:20:31 UTC
Nineteen publishes in eight minutes.
Starting with
ansi-styles@6.2.2at 13:12:10, thendebug@4.4.2andchalk@5.6.1, each package got one new patch version, ending withbackslash@0.2.1at 13:20:31.5Each was “functionally identical to the previous patch version, but with a malware payload added”. To npm it was a valid publish from the owner's account, so it became the newest version. Nothing tied a publish to the source repository or to CI.6
- first
- ansi-styles 6.2.2 · 13:12:10
- last
- backslash 0.2.1 · 13:20:31
- versions
- 19, one per package
- contents
- previous patch + payload
- repo commit / tag
- none
Semver ranges pull the new patch in, and bundlers ship it.
Any fresh install or lockfile update whose range allowed the new patch (
^4.4.1, say) picked it up automatically. These are tiny utilities deep in almost every dependency tree.Bundlers such as webpack, Vite, Rollup and Next.js then copied the utility code into front-end JavaScript. Builds pinned by a committed lockfile and installed with
npm ci(or an equivalent) did not move.6- range
- ^4.4.1 → 4.4.2
- lockfile + npm ci
- unchanged
- bundler
- copies it into front-end JS
Dormant on servers, awake in browsers.
The theft logic ran only where browser globals exist. The debug advisory: “Local environments, server environments, command line applications, etc. are not affected.” In some Node builds and CI runs it threw errors because
fetchwasn't defined, which helped people spot it.616In a web page it took over three things: the page's
fetchandXMLHttpRequest, so it could rewrite responses; injected wallet providers such aswindow.ethereum; and Solana-style wallet interfaces.14- Node, CI, servers
- dormant
- browser
- active
- network
- fetch, XMLHttpRequest
- wallets
- window.ethereum, Solana-style
A look-alike address, in the page and in the wallet.
Addresses for ETH, BTC, SOL, TRX, LTC and BCH in page data were replaced with the attacker address that looked most similar, picked by edit (Levenshtein) distance so a glance would not catch it.
With a wallet present, outgoing transactions and token
approveandtransfercalls were rewritten to benefit the attacker. Aikido: it “avoids obvious swaps in the UI to reduce suspicion”. The theft still needed the user to sign the altered transaction.14- chains
- ETH, BTC, SOL, TRX, LTC, BCH
- pick
- nearest look-alike address
- wallet calls
- approve, transfer rewritten
- needs
- the user's signature
8 Sep · 13:16 → ~19:58 UTC
Caught in minutes, removed in hours, cleaned up for days.
Aikido's feed alerted at 13:16; a user opened the debug issue at 13:35. Sindre Sorhus, co-maintainer of the chalk packages, published clean versions over the chalk family from 14:47. Junon confirmed at 15:15: “Yep, I've been pwned.” npm told him at about 19:58 UTC: “All impacted package versions have been taken down.”2
Private registry mirrors and Deno caches kept serving the bad versions, so on 12–13 September Junon published “cache-bust” versions above them, identical to the last good ones. Vercel purged build caches for the 76 projects whose builds had picked them up.9
- first alert
- Aikido, 13:16 UTC
- clean chalk
- 5.6.2 at 14:47 UTC
- all taken down
- ~19:58 UTC
- cache-bust
- 12–15 Sep
The numbered markers on the diagram match the steps.
It was a 2FA reset email that looked shockingly authentic. I should have paid better attention, but it slipped past me
Nineteen publishes in eight minutes. The alert came during the fourth.
- 8min 21s
from the first malicious publish to the last.
- 4min
until Aikido's feed flagged the new versions.
- 2h 04min
until the maintainer confirmed in public.
- ~6h 46min
until npm said every impacted version was down.
Why the window was “about two hours”, and also longer
Wiz describes a “2-hour exposure window”, from the compromise to the maintainer's acknowledgement. The chalk family stopped being the newest version between 14:47 and 15:14 UTC, when clean versions replaced them. The others stayed downloadable until npm removed them, between about 16:33 (debug) and 19:58 UTC.
All impacted package versions have been taken down.
Enormous reach, a tiny take, and a costly clean-up.
- Weekly downloads (Aikido)over 2 billion for 18 packages
- Cloud environments (Wiz)1 in 10 reached in about two hours
- Vercel70 teams, 76 projects built with a bad version
- Stolen (SEAL)about 5¢ of ETH + about $20 of a memecoin
Aikido put the 18 packages in its table at over 2 billion weekly downloads; Socket says “2-3 billion downloads per week”. The figures overlap heavily, because one install pulls several of these packages.1415
Wiz found the malicious code in 10% of cloud environments within about two hours, and at least one of the packages in 99% of them. Vercel counted 70 teams and 76 unique projects whose builds contained the compromised versions, and purged their build caches.179
The money was another matter. sources disagree SEAL's on-chain analysis traced about 5 cents of ETH and about $20 of an illiquid memecoin on the day, under the title “Oops, No Victims: The Largest Supply Chain Attack Stole 5 Cents”. Sygnia wrote “approximately $500” three days later, without stating its basis. Both are researchers' figures; neither npm nor GitHub has published one.1618
The real cost was the industry's response: Wiz called it more of a “denial-of-service attack on the industry”. In July 2026 Amazon attributed the incident, at medium confidence, to a DPRK-linked group; The Hacker News noted the published evidence is thin. vendor attribution1920
A passkey would have left the fake page nothing to pass on.
On 22 September 2025, after this incident and the Shai-Hulud worm, GitHub announced it would “Deprecate time-based one-time password (TOTP) 2FA, migrating users to FIDO-based 2FA”, and remove the option to bypass 2FA for local publishing. The post itself cites Shai-Hulud, not this incident.10
Publishing only from CI with trusted publishing (OIDC), and setting a package to “Require two-factor authentication and disallow tokens”, means a phished web login can't mint a publish token. The DuckDB attacker published with a newly created API token. TanStack's compromise in May 2026 shows the limit: OIDC can still be abused from inside a compromised workflow.11
One irony from the recovery: Junon reported that the npm CLI wouldn't let him publish “without downgrading to TOTP”.1
Local environments, server environments, command line applications, etc. are not affected.
The fix lived in deployed sites, not in node_modules.
Upgrading a package doesn't recall the JavaScript a site already served. A front end built from a fresh install during the window could carry the payload until it was rebuilt and redeployed, and CDN and edge caches could keep the old assets after that.17
- Check lockfiles and SBOMs for the exact versions; ranges alone don't tell you.
- Find builds made between 8 Sep 13:12 and about 20:00 UTC, longer behind private mirrors.
- Rebuild and redeploy those front ends from clean dependencies; purge CDN copies.
- Purge
node_modules, package-manager and CI caches, private mirrors, andDENO_DIR. - Blocklist the versions in registry proxies; pin or upgrade to clean versions.
- Scan shipped bundles for the payload's obfuscated markers.
Ordinary credential rotation wasn't needed for this code, unlike the Shai-Hulud worm a week later: the advisory scopes the impact to browsers. inference6
Phishing-resistant 2FA would have stopped it at the door.
What would have broken the chain, mapped to the steps above.
| Control | Effect here | Why |
|---|---|---|
| Passkeys / WebAuthn (FIDO) for maintainers | Breaks step 2 | The credential is bound to npmjs.com, so a look-alike domain gets nothing to relay. GitHub has announced moving npm from TOTP to FIDO-based 2FA (Fig. 3). |
| Trusted publishing, tokens disallowed | Breaks step 4 | With “Require two-factor authentication and disallow tokens”, a phished web login can't mint a publish token. caveat It moves the risk into the CI workflow, as TanStack found in 2026. |
| Lockfile + frozen install | Breaks step 5 | Builds that installed from a committed lockfile (npm ci, pnpm install --frozen-lockfile) never picked up the new patches. Vercel recommends exactly this. |
| Minimum release age | Breaks step 5 | Every version was off the public registry within about seven hours. pnpm's minimumReleaseAge (minutes; default 1440 since v11) would have skipped them all; npm has min-release-age (days) and before.13 |
| Rebuild front ends built in the window | Ends step 7 | Redeploy from clean dependencies and purge CDN and edge copies: the payload lived in served JavaScript, not on build machines. |
| Check the domain, not the branding | Weakens step 1 | npmjs.help isn't npmjs.com. DuckDB noted the password manager “did not auto-complete the login”, which “should have been a red flag”. |
| Registry monitoring | Shortens the window | Aikido's feed alerted four minutes in. A sudden patch of a dormant, high-download package, with no matching repository tag, is a strong signal. |
Sources
Primary sources are the maintainers, the registry, advisory databases, the platform operator and package-manager documentation. Secondary sources are the discoverers' and researchers' own analyses and the press.
Primary · maintainers
- 1Josh Junon (Qix-) and usersdebug-js/debug#1005 · CVE list
The admission, lockout, package list, npm's messages, cache-bust releases, DENO_DIR.
- 2Josh JunonBluesky posts
The acknowledgement, debug yanked, npm's first contact and the takedown message.
- 3Josh JunonThe phishing email's full headers
Sender, subject, Date: 00:50:20 +0000, the lure's wording.
- 4Sindre Sorhus and userschalk/chalk#656
Clean republishes, browser-only behaviour, the detection search.
Primary · registry, advisories, operators and docs
- 5npm registryregistry.npmjs.org/debug and the other 18 packages
Exact publish times, last good and first clean versions.
- 6Josh Junon via GitHubGHSA-4x49-vf9v-38px (debug)
Browser-only impact, patched versions, remediation.
- 7GitHub Advisory DatabaseGHSA-2v46-p5h4-248w (chalk) and one advisory per package
GHSA and CVE identifiers, severities, withdrawn duplicates.
- 8DuckDB maintainers via GitHubGHSA-w62p-hx95-gf2c
The same phish, the relay of username, password and 2FA, a new API token.
- 9VercelCritical npm supply chain attack response
70 teams, 76 projects, cache purge, recommendations.
- 10GitHubOur plan for a more secure npm supply chain
TOTP to FIDO, token restrictions, trusted publishing.
- 11npmTrusted publishers
OIDC publishing; disallowing tokens.
- 12pnpmDependency resolution settings
minimumReleaseAge, 1440 minutes by default since v11.
- 13npmnpm config
min-release-age and before.
Secondary · discoverers, researchers, press
- 14Aikido (Charlie Eriksen)npm debug and chalk packages compromised
13:16 detection, weekly downloads, payload behaviour, the attacker's address.
- 15Socketnpm author Qix compromised in major supply chain attack
2:50 AM local email time, 2-3 billion weekly downloads.
- 16Security Alliance (samczsun)Oops, No Victims: The Largest Supply Chain Attack Stole 5 Cents
On-chain analysis of the take; fetch errors in CI.
- 17WizWidespread npm supply chain attack: breaking down impact and scope
10% and 99% of cloud environments, the 2-hour window, remediation.
- 18Sygnianpm supply chain attack, September 2025
The ~$500 figure and a 13:00 UTC email time, both at odds with other sources.
- 19AmazonAmazon identifies North Korean hacker group behind open source supply chain attacks
Medium-confidence attribution.
- 20The Hacker NewsAmazon links debug and chalk npm hijack
Scepticism about the attribution's evidence.
Written from sources checked on 2026-10-01. Where sources disagree (the email's send time, the amount stolen, the package count) each figure is attributed to its source. Which second factor the qix maintainer entered is not public; the relay is described from the DuckDB maintainers' account of the same lure. npm's words are known only through the maintainer's relays. Pull quotes are verbatim from the maintainer and the debug advisory.