Shai-Hulud Broke npm's Trust Model. Here's the Replacement
On September 14, 2025, at 17:58 UTC, a maintainer published what looked like a routine update to @ctrl/tinycolor, a small, unremarkable color-manipulation package used deep in a lot of dependency trees. It wasn't routine. Within hours, researchers had traced a self-replicating worm, later named Shai-Hulud, that stole the publishing tokens on a developer's machine and used them to automatically push trojanized versions of every other package that developer controlled. No command-and-control server, no human attacker manually working through a target list. The worm did that part itself.
Researchers counted 187 compromised packages in the first wave. That number kept climbing as the worm kept spreading through freshly stolen tokens, eventually passing 500. It briefly reached packages published under CrowdStrike's own npm namespace before they were pulled. It's the first widely observed self-replicating worm in the npm registry's history, and it's a large part of why 2025's ReversingLabs Software Supply Chain Security Report (opens in a new tab) found malicious open-source package detections up 73% year over year, with npm alone accounting for roughly 90% of them.
That's the headline. The part worth actually sitting with is how it worked, because it changes what "supply chain security" needs to mean for a team that just runs npm install like everyone else.
The attack didn't need a vulnerability
Shai-Hulud didn't exploit a bug in npm, a flaw in a dependency, or a clever piece of malicious code hidden in a diff. It exploited something much more mundane: developers routinely have long-lived npm publish tokens sitting on their machines, in .npmrc files, CI secrets, and shell profiles, because that's how publishing has worked for the better part of two decades.
Once a single machine was compromised, the worm used tools like TruffleHog to scan for those tokens, along with GitHub personal access tokens and cloud credentials sitting nearby. It added a malicious GitHub Actions workflow to exfiltrate whatever it found, then used the stolen npm token to republish every package that maintainer had access to, with the same worm bundled inside. Each new compromised package became a new distribution point. That's the self-replicating part, and it's why this scaled faster than a typical supply chain attack: the attacker's job was done after the first infection. The worm did the propagation.
The failure here isn't a careless maintainer. It's a token model where a single stolen credential grants standing, indefinite permission to publish to the entire npm registry on that person's behalf, with no per-run scope and no expiration forcing a reset. That's the actual thing that needed fixing, and it's what npm and GitHub spent the rest of 2025 rebuilding.
What replaced the long-lived token
The instinct after an incident like this is usually "rotate more often" or "review token permissions more carefully." That's not what shipped. Instead, GitHub's response (opens in a new tab), phased in through December 2025, removes the long-lived token from the publishing flow entirely for teams that adopt it.
Trusted publishing uses OpenID Connect so a CI workflow authenticates to npm directly. There's no NPM_TOKEN sitting in a secrets store for a worm to find, because there's no persistent token at all. npm validates the identity of the specific GitHub Actions workflow requesting the publish, mints a short-lived, workflow-scoped credential for that single run, and the credential expires the moment the job finishes. Steal the workflow file and you still don't get a standing key to the registry.
Setting it up for a package you maintain looks roughly like this in the workflow that publishes it:
permissions:
id-token: write # required for OIDC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm publish --provenance
The --provenance flag is the second half of the fix: npm's CLI automatically generates a provenance attestation for the package, cryptographic proof of the exact repository, workflow, and commit that produced it. Anyone installing the package can verify where it actually came from, not just what the README claims.
For anything still publishing manually, the other half of the announcement matters just as much: GitHub is deprecating classic tokens for npm publishing, capping granular token lifetimes sharply, removing the option to bypass 2FA for local publishing, and phasing out TOTP-based 2FA in favor of FIDO security keys and passkeys. None of that is a suggestion with a distant deadline. It's the direction the whole registry is moving, whether or not a given team has opted in yet.
What provenance doesn't buy you
It's worth being precise about what this actually solves, because the checkmark next to a provenance-verified package invites more confidence than it's earned. Provenance proves which pipeline produced a package. It doesn't prove the pipeline was honest.
If an attacker compromises the source repository itself, say, by getting a malicious commit merged, or gaining GitHub Actions access some other way, trusted publishing will faithfully generate a provenance attestation for that maliciousness. It'll be cryptographically correct and functionally useless. This is the same gap reviewing AI-generated code runs into: verifying that something came from where it claims to have come from is not the same task as verifying it's safe to trust. Provenance narrows the attack surface to "compromise the actual pipeline" instead of "steal a token from anywhere," which is a real improvement, not a complete one.
What to actually do about it this week
For a team that isn't publishing its own packages, the relevant question isn't "should we adopt trusted publishing" so much as "how exposed are we to someone else's compromised token." A few concrete steps that don't require waiting on anyone else:
- Pin dependencies with a committed lockfile, and treat
npm cias the only install command CI is allowed to run. A worm that republishes a patch version doesn't reach a build that's locked to an exact, previously audited version. - Audit before bumping, not after. A version bump on a package with low download counts or a maintainer who just changed is worth a second look before it merges, the same instinct as reviewing any other unfamiliar diff before trusting it.
- Get long-lived
NPM_TOKENsecrets out of CI for anything your team does publish, and move to trusted publishing where the CI platform supports OIDC. GitHub Actions, GitLab CI, and CircleCI all do now. - Turn on FIDO-based 2FA for every account with publish access, ahead of npm forcing the TOTP deprecation. Doing it on your own schedule beats doing it during an incident.
- Run a dependency scanner in CI, not as a replacement for the above, but as the layer that catches what slips past it. Socket, Snyk, and similar tools flag install scripts, new maintainers, and suspicious version jumps before they reach a build.
None of these require a security team. They're the same discipline as a written PR checklist applied one layer down the stack, to the code a team didn't write but ships anyway.
Bringing it together
Shai-Hulud didn't need a zero-day. It needed a token model built on standing trust with no expiration, and it turned that into a worm that spread itself. npm's response doesn't ask maintainers to be more careful with tokens, it removes the long-lived token from the equation through OIDC-based trusted publishing, backs it with cryptographic provenance, and forces stronger 2FA across the board. That's a real fix, not theater, but it answers "did this come from where it claims to" and not "is it safe." For a team not ready to migrate its own publishing today, the more urgent move is smaller and faster: locked dependencies, audited version bumps, and 2FA turned on before npm turns it on for you.