Syed Hasibur Rahman27 min read
The Token That Published the Worm: Trusted Publishing, Sigstore, and Provenance After Shai-Hulud
Shai-Hulud did not exploit npm. It read publish tokens out of developers' .npmrc files and environment variables, used them to push poisoned versions of the victims' other packages, and repeated. The fix that the registries converged on is trusted publishing: CI proves its identity with a short-lived OIDC token and receives a publish credential that expires within minutes, with signed SLSA provenance recorded in a public transparency log. This post walks through the exchange claim by claim, shows working GitHub Actions and GitLab CI configurations, traces npm's token changes from the 2025 plan to the 2026 reality, and is precise about what trusted publishing does not protect: a compromised build.
Open the home directory of almost any JavaScript developer who has ever published a package and look for a file called .npmrc. There is a good chance it contains a line that starts //registry.npmjs.org/:_authToken=, followed by a string the developer pasted in once, years ago, and has not thought about since. Search the CI settings of the same developer’s repositories and you will usually find that same string again, stored as a secret called NPM_TOKEN, so that the release workflow can run npm publish without anyone typing a password.
That string is the most valuable thing on the machine. It does not expire on any schedule the developer chose. It is not bound to the laptop, the repository, or the workflow. Anyone who reads it can publish a new version of every package the developer maintains, and those versions will be installed automatically by every downstream project whose version range allows it.
In September 2025 somebody wrote a program that reads it.
The program was named Shai-Hulud, after the sandworms in Dune, and it spread through the npm registry in mid-September. StepSecurity’s analysis describes the core loop: the malware “parses NPM_TOKEN from either ~/.npmrc or environment variables”, validates it against the registry’s /whoami endpoint, queries the registry for packages owned by that maintainer, and “force-publishes patches to these packages”1. Every newly infected package carried the same payload to its own installers. Some of those installers were maintainers with their own tokens. CISA’s alert on 23 September put the count at “over 500 packages”2. GitHub, which runs npm, removed more than 500 compromised packages and published a plan to change how publishing works3.
The plan, and what npm and PyPI shipped in the year that followed, is the subject of this post. The short version: the registries are retiring the long-lived publish token. In its place they use trusted publishing. The build proves which repository and workflow it is, using a short-lived OpenID Connect token. The registry then hands it a publish credential that lives for minutes, and signed provenance records which build produced which package. The long version matters more, because trusted publishing has a precise threat model. Teams that adopt it without understanding that model get a false sense of having solved the problem.
1. The credential that outlives the build
Start with what a registry publish token actually is. PyPI’s documentation states the problem in one sentence:
“PyPI’s normal API tokens are long-lived, meaning that an attacker who compromises a package’s release token can use it until its legitimate user notices and manually revokes it.”4
Three properties make this kind of credential dangerous, and they compound:
- Lifetime is decoupled from use. A release takes perhaps two minutes of CI time, a handful of times a month. The token exists for all the other hours too: in a secret store, in the environment of every job that can read the secret, in a dotfile on a laptop, in shell history, in a backup.
- Scope is decoupled from context. A classic npm token is not bound to one repository, one workflow, or one machine. If it can publish
left-pad, it can do so from anywhere on the internet. - Detection is human. As PyPI’s sentence says, the attacker’s window lasts until a legitimate user notices. With a worm, the attacker’s tooling moves at machine speed and the defenders notice at human speed.
There is a fourth property that only matters at ecosystem scale: the tokens are fungible across victims. A worm does not need to understand your project. It needs one string in one predictable place, and every maintainer keeps that string in the same place. Shai-Hulud works because ~/.npmrc and the NPM_TOKEN environment variable are conventions.
If you have read our earlier post on workload identity and SPIFFE, this is the same argument applied to a specific and unusually high-value credential: “attest, then issue short-lived” beats “store a secret and hope”. Package publishing turned out to be the place where the industry learned that lesson most expensively.
2. What happened in September 2025
Two separate incidents a week apart are usually blurred together. They are worth separating, because they compromised accounts in different ways and the fixes address them differently.
The chalk/debug compromise (8 September). Wiz’s write-up opens: “On September 8th, 2025, at around 9AM EST, a threat actor had managed to gain control of the npm account of well-known developer Qix via social engineering.”5 The attacker published malicious versions of widely used packages including chalk and debug. The payload targeted browsers. It wrapped fetch, XMLHttpRequest, and wallet interfaces such as window.ethereum.request, and silently rewrote transaction recipients to attacker-controlled addresses. The malicious versions were available for a “short 2-hour timeframe”5. Wiz’s measurement of the blast radius is the number to remember: “99% of cloud environments had at least one instance of one of the packages targeted”, and after release “the malicious code itself could be found in at least 10% of cloud environments”5. Two hours was enough.
This was an account compromise. The attacker obtained the maintainer’s ability to publish by tricking the human, not by stealing a token from a machine.
Shai-Hulud (mid-September). The worm was different in kind. GitHub’s description is “a self-replicating worm that infiltrated the npm ecosystem via compromised maintainer accounts by injecting malicious post-install scripts into popular JavaScript packages”3. Each infected version ran code at install time. That code harvested secrets from the machine it landed on and used any npm token it found to republish the victim’s packages. StepSecurity found that it also planted a GitHub Actions workflow at .github/workflows/shai-hulud-workflow.yml, designed to “exfiltrate repository secrets using the expression ${{ toJSON(secrets) }}”1. CISA noted that the actor “then targeted GitHub Personal Access Tokens (PATs) and application programming interface (API) keys for cloud services, including Amazon Web Services (AWS), Google Cloud Platform (GCP), and Microsoft Azure”2.
CISA’s mitigations are instructive for what they reveal about the response options available to a defender at that moment2:
- “Immediately rotate all developer credentials.”
- “Mandate phishing-resistant multifactor authentication (MFA) on all developer accounts, especially for critical platforms like GitHub and npm.”
- “Pin npm package dependency versions to known safe releases produced prior to Sept. 16, 2025.”
Every item is a manual act performed after the fact. When all you have is long-lived secrets, rotation is the only remedy, and it does not scale to an ecosystem.
The same week on PyPI. On 16 September PyPI published its own incident note about a campaign that modified GitHub Actions workflows to steal PyPI publishing tokens stored as repository secrets. The outcome was better: “PyPI was not compromised, and no PyPI packages were published by the attackers.”6 PyPI’s recommendation was the same as everybody’s: stop storing long-lived tokens and use Trusted Publishers.
The sequel (November 2025). Unit 42 tracked a second wave, Shai-Hulud 2.0, which “includes over 25,000 malicious repositories across about 350 unique users”. It ran its payload during pre-install rather than post-install. For persistence it created a workflow that “registers the infected machine as a self-hosted runner”7. Keep that last detail in mind for section 8.
3. Why “rotate your tokens” was never the answer
The instinctive engineering response to a leaked-credential incident is hygiene: shorter expiry, rotation reminders, scoped tokens, secret scanning. All of it helps at the margin. None of it removes the property the worm exploited, which is that a publish capability exists as a copyable string at rest.
Consider what each measure buys:
| Measure | What it changes | What it leaves |
|---|---|---|
| Granular (per-package) tokens | Fewer packages per stolen token | Still copyable, still usable from anywhere |
| Expiry (e.g. 90 days) | Stale tokens eventually die | A worm needs minutes, not months |
| Rotation | Shortens the useful life of one leak | Each new token is just as stealable |
| Secret scanning | Catches tokens committed to git | Worms read files and env vars, not commits |
| 2FA on publish | Stops a stolen token from publishing alone | CI cannot answer a 2FA prompt, so CI tokens bypass it |
The last row is the trap. Publish-time 2FA is effective against a stolen token on a laptop. But automated releases cannot type a one-time code, so registries offered “automation” tokens that skip 2FA, and those are exactly the tokens that live in CI secret stores. The security control and the automation requirement were in direct conflict, and automation won.
Trusted publishing resolves that conflict by changing what CI presents. Instead of a secret it was given in advance, CI presents an identity token minted for this job, which nobody stored, plus a claim set describing exactly which repository, workflow, and ref is running.
4. Trusted publishing: identity instead of a secret
PyPI shipped the idea first, in April 2023. Dustin Ingram’s announcement defines it as “using the OpenID Connect (OIDC) standard to exchange short-lived identity tokens between a trusted third-party service and PyPI”8. npm followed with general availability on 31 July 20259. Both work the same way, in four steps.
Step 1: the maintainer registers a trust relationship, not a credential. On the registry, the maintainer tells the project, in effect: “accept publishes from repository acme/widget, workflow release.yml, optionally environment pypi.” No secret is generated or copied anywhere.
Step 2: the CI job asks its own platform for an OIDC token. On GitHub Actions this requires the job to hold the id-token: write permission. GitHub’s documentation is explicit: “Without id-token: write, the OIDC JWT ID token cannot be requested.”10 On GitLab the job declares an id_tokens block with the registry as the audience. The token is a JWT signed by the CI platform’s issuer, which for GitHub is https://token.actions.githubusercontent.com10.
Step 3: the job presents the token to the registry. The registry verifies the JWT signature against the issuer’s published keys and checks the claims against the registered trust relationship. GitHub’s reference lists the claims that matter, with examples like these10:
{
"iss": "https://token.actions.githubusercontent.com",
"aud": "https://github.com/octo-org",
"sub": "repo:octo-org/octo-repo:ref:refs/heads/main",
"repository": "octo-org/octo-repo",
"repository_owner": "octo-org",
"ref": "refs/heads/main",
"environment": "Production",
"job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main"
}
job_workflow_ref deserves attention. GitHub defines it as, “for jobs using a reusable workflow, the ref path to the reusable workflow”10. It lets a registry pin trust to the release workflow, not merely to anything that runs in the repository. Without it, a test job, a lint job, or a workflow added in a pull request could all present a valid token for the same repository.
Step 4: the registry mints a short-lived publish credential. PyPI’s token “behaves exactly like a normal project-scoped API token, except that it’s only valid for 15 minutes from time of creation (enough time for the CI to use it to upload packages)”4. npm describes its equivalent as “short-lived, cryptographically-signed tokens that are specific to your workflow and cannot be extracted or reused”11. PyPI’s documentation even shows the raw exchange for platforms without a packaged action12:
resp=$(curl -X POST https://pypi.org/_/oidc/mint-token -d "{\"token\": \"${oidc_token}\"}")
api_token=$(jq -r '.token' <<< "${resp}")
If you know OAuth, this pattern is familiar. It is the same idea as RFC 8693 OAuth 2.0 Token Exchange, which “defines a protocol for an HTTP- and JSON-based Security Token Service (STS)”13. You present a subject_token from one trust domain, receive a token for another audience. The registries do not claim to implement RFC 8693 on the wire; PyPI’s endpoint is its own small JSON API. The shape of the trust is the same, though, and so is the lesson: credentials should be derived on demand from an identity, not stored in advance as a secret.
The original PyPI announcement summarised the benefits in a sentence that holds up well: “These API tokens never need to be stored or shared, rotate automatically by expiring quickly, and provide a verifiable link between a published package and its source.”8 All three properties in section 1 are reversed. Lifetime is tied to use, scope is tied to context, and nobody has to notice anything for the credential to die.
5. What the registry is actually checking
It helps to write the verifier yourself once, even if you will never run it in production, because it makes the trust decision concrete. The following is illustrative: a minimal Python sketch of the checks a registry performs on a GitHub Actions OIDC token, not PyPI’s or npm’s actual code. It uses PyJWT.
import jwt
from jwt import PyJWKClient
ISSUER = "https://token.actions.githubusercontent.com"
AUDIENCE = "my-registry" # whatever audience the registry asks CI to request
# What the maintainer registered: no secret, only a description of the publisher.
TRUSTED_PUBLISHERS = {
"widget": {
"repository": "acme/widget",
"workflow": "acme/widget/.github/workflows/release.yml",
"environment": "release", # optional, but strongly recommended
},
}
_jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks")
def authorize_publish(project: str, oidc_token: str) -> dict:
signing_key = _jwks.get_signing_key_from_jwt(oidc_token)
claims = jwt.decode(
oidc_token,
signing_key.key,
algorithms=["RS256"],
audience=AUDIENCE, # rejects tokens minted for anyone else
issuer=ISSUER, # rejects tokens from any other CI platform
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
pub = TRUSTED_PUBLISHERS.get(project)
if pub is None:
raise PermissionError("no trusted publisher configured")
if claims.get("repository") != pub["repository"]:
raise PermissionError("wrong repository")
# Pin to the release workflow, not merely the repository.
# job_workflow_ref looks like "<owner>/<repo>/.github/workflows/<file>@<ref>".
workflow, _, _ref = claims.get("job_workflow_ref", "").partition("@")
if workflow != pub["workflow"]:
raise PermissionError("wrong workflow")
if pub.get("environment") and claims.get("environment") != pub["environment"]:
raise PermissionError("wrong environment")
# Authorized: mint a credential scoped to this one project, expiring in minutes.
return {"project": project, "ttl_seconds": 15 * 60, "subject": claims["sub"]}
Every rejection in that function closes a specific hole:
issuer: a token from some other OIDC provider, even a validly signed one, is worthless here.audience: a token GitHub minted for your cloud provider cannot be replayed at the registry. This is the same audience-restriction discipline OAuth deployments learned the hard way.repository: a fork or a lookalike repository gets nothing.job_workflow_ref: a different workflow in the same repository gets nothing. This is the check that stops a malicious pull-request workflow from publishing.environment: lets the maintainer put a human approval gate or branch protection in front of release jobs, because GitHub environments can require reviewers before a job that targets them may run.
Notice what is not in the function: any comparison against a stored secret. There is nothing to leak because there is nothing stored.
6. Wiring it up
Configuration has become pleasantly boring on both major CI platforms.
npm on GitHub Actions
npm’s trusted publishing requires “npm CLI v11.5.1 or later”9 (and Node 22.14.0 or later, per the docs11). The workflow needs id-token: write, and no NODE_AUTH_TOKEN secret:
name: release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: release # gate with required reviewers in repo settings
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- run: npm install -g npm@latest # ensure >= 11.5.1
- run: npm ci
- run: npm test
- run: npm publish # no token: the CLI performs the OIDC exchange
Two details from npm’s GA announcement are easy to miss. First, provenance is automatic: “When using trusted publishing, npm CLI publishes provenance attestations by default.”9 You no longer need --provenance. Second, the supported runners are narrow: GitHub-hosted runners and GitLab.com shared runners at GA, with CircleCI cloud added since. Self-hosted runners are not supported11. That restriction is a feature, not an oversight, as we will see in section 8.
Once trusted publishing works, npm’s documentation recommends going back to the package settings and selecting “Require two-factor authentication and disallow tokens”11. This step is the one that matters. Adding trusted publishing without disallowing tokens leaves the old door open beside the new one.
PyPI on GitLab CI
PyPI’s documentation gives this GitLab example verbatim12:
build-job:
stage: build
image: python:3-bookworm
script:
- python -m pip install -U build
- cd python_pkg && python -m build
artifacts:
paths:
- "python_pkg/dist/"
publish-job:
stage: deploy
image: python:3-bookworm
dependencies:
- build-job
id_tokens:
PYPI_ID_TOKEN:
aud: pypi
script:
- python -m pip install -U twine
- twine upload python_pkg/dist/*
Note the structure: the build runs in one job and the publish in another, and only the publish job requests an id_tokens entry. The job that executes your project’s build scripts, and therefore your dependencies’ code, never holds the identity that can publish. It is a small separation, and it is exactly the right one.
On GitHub Actions the PyPI equivalent is pypa/gh-action-pypi-publish with id-token: write. PyPI’s docs say that specifying a GitHub environment “is optional, but strongly encouraged”12. PyPI has also extended trusted publishing beyond the public SaaS platforms. In November 2025 it announced a beta for GitLab Self-Managed instances, and support for pending publishers owned by Organizations14.
7. Signing without keys: Sigstore, Fulcio, Rekor
Trusted publishing answers who may publish. A consumer installing a package wants a different question answered: where did these bytes come from? That is the job of provenance, and in both ecosystems it rests on Sigstore.
The Sigstore design was published at ACM CCS 2022 by Zachary Newman, John Speed Meyers, and Santiago Torres-Arias15. Its central move is the same one trusted publishing makes: replace long-lived keys with identity. Software signing had failed for decades largely because key management was too hard; Sigstore removes the key management.
The pieces, as npm’s 2023 provenance announcement describes them in practice16:
- Ephemeral keys. The CI job generates a keypair, uses it to sign once, and discards it. There is no private key to steal after the job ends.
- Fulcio, a certificate authority that accepts the CI job’s OIDC token and issues an X.509 certificate binding the ephemeral public key to the job’s identity. Sigstore’s documentation: “Fulcio signs X.509 certificates valid for 10 minutes.”17 For a CI signer, the certificate binds the key to the CI job’s identity, meaning which workflow in which repository signed rather than which person16.
- Rekor, a public, append-only transparency log. The signature and attestation are recorded there, so a signature made with a short-lived certificate stays verifiable after the certificate expires, and so nobody, including the registry, can quietly issue a signature that is not publicly visible.
The resulting attestation, in npm’s words, lets maintainers “verifiably link a package back to its source repository and the specific build instructions used to publish it”16.
Rekor itself was re-architected in 2025. Rekor v2 reached general availability on 10 October 2025, “transitioning its backend to a tile-backed transparency log implementation to simplify maintenance and lower operational costs”, and supporting just two entry types, hashedrekord for artifacts and dsse for attestations18. Client rollout was staged. Cosign and the Go, Python, and Java clients came first, and “Support for JavaScript and Ruby will follow”18. That matters if you are reasoning about which log an npm attestation lives in.
PyPI completed the same picture on its side in November 2024, when it finalised support for PEP 740 digital attestations. Attestations are generated automatically for projects using Trusted Publishing through pypa/gh-action-pypi-publish19.
8. What provenance promises, precisely: SLSA build levels
Provenance is only as trustworthy as the build platform that produced it. SLSA (Supply-chain Levels for Software Artifacts) gives the vocabulary for how trustworthy that is. The current version is v1.2, announced on 24 November 2025; it adds a Source Track and “is backwards compatible with SLSA v1.1”20. Its Build Track defines three levels, and the one-line summaries are worth reading literally21:
- Build L1: “Package has provenance showing how it was built. Can be used to prevent mistakes but is trivial to bypass or forge.”
- Build L2: “Forging the provenance or evading verification requires an explicit ‘attack’, though this may be easy to perform.”
- Build L3: “Forging the provenance or evading verification requires exploiting a vulnerability that is beyond the capabilities of most adversaries.”
L3’s stated benefits include that it “greatly reduces the impact of compromised package upload credentials”21. Its requirements include the ability to “prevent secret material from being accessible to user-defined build steps”21. That one clause explains most of the design choices in section 6:
- Why npm’s trusted publishing supports only platform-hosted runners: a self-hosted runner is a machine the project owner controls. The provenance it emits describes whatever its owner, or whoever compromised it, wanted. Shai-Hulud 2.0 registered infected machines as self-hosted runners7. A registry that trusted provenance from self-hosted runners would have been trusting the worm.
- Why the build and publish steps should be separate jobs: user-defined build steps (your
postinstall, your test suite, every transitive dependency’s install script) must not be able to read the material that authorises a publish.
The underlying data model comes from in-toto, presented at USENIX Security 2019 by Torres-Arias, Afzali, Kuppusamy, Curtmola, and Cappos. Its aim was to let “the end user … verify the software’s supply chain from the project’s inception to its deployment”, and its evaluation covered 30 real supply-chain compromises22. SLSA provenance is an in-toto attestation: a signed statement binding an artifact’s digest to a predicate describing how it was built.
9. What trusted publishing does not fix
This is the section to read twice. Trusted publishing removes one class of attack, the theft of a standing publish credential. It does nothing for an attacker who can run code inside the trusted build.
Case 1: Ultralytics (December 2024). PyPI’s post-mortem on the compromise of the ultralytics package begins: “No security flaw in PyPI was used to execute this attack.”23 The investigators worked out what happened from the provenance itself: “From looking at the Sigstore transparency logs and the PyPI provenance attestations, it was clear that the first set of injected packages were published through the existing GitHub Actions workflow, not by an API token.”23 The attacker had poisoned the GitHub Actions cache: “the attack targeted the GitHub Actions cache which was used during the build phase”23. The workflow was the trusted publisher. The OIDC token was genuine. The provenance was accurate: these bytes really were built by that workflow. The bytes were malicious anyway. Provenance did its job: it recorded which build produced the release. It cannot say whether that build was honest.
Then the second wave: “The second round of malicious releases came from the attacker using an unrevoked PyPI API token that was still available to the GitHub Actions workflow”23. The project had adopted trusted publishing but never deleted the old token. This is the npm “disallow tokens” setting from section 6, learned the expensive way.
PyPI’s advice is specific: “Avoid the use of commonly insecure patterns, such as GitHub Action’s ‘pull_request_target’ or shared caches during the build and release process.”23
Case 2: tj-actions/changed-files (March 2025). The version tags of a widely used GitHub Action were retroactively changed to point at a malicious commit. The GitHub advisory describes a script that “targeted the Runner Worker process, extracting and exfiltrating its memory contents” and printed the secrets it found “in GitHub Actions logs, making them publicly accessible in repositories with public workflow logs”24. CISA recorded the result as “information disclosure of secrets including, but not limited to, valid access keys, GitHub Personal Access Tokens (PATs), npm tokens, and private RSA keys”25, and added it and the upstream reviewdog/action-setup compromise (CVE-2025-30154) to the Known Exploited Vulnerabilities catalog. Here trusted publishing does help, but only partially. There was no npm token in the secrets to leak, and an OIDC token in the log would have expired in minutes and been bound to the wrong audience. But a compromised Action running in the publish job itself could still request a fresh OIDC token and publish with it.
The general shape: trusted publishing moves the security boundary from the secret store to the build definition. Whoever can change what runs in your release job can publish as you. So the controls that matter after adoption are the ones that protect the release job:
- Pin third-party Actions to full commit SHAs, not tags. Tags are mutable, and that mutability is what tj-actions exploited.
- Never let untrusted input reach the release workflow: no
pull_request_target, no shared caches between PR builds and release builds. - Put the release job behind a protected environment with required reviewers, and pin the trusted publisher to that environment.
- Keep
id-token: writeon the publish job only, never at the workflow level.
10. The registries close the door: npm’s token timeline
The most useful thing about npm’s response is how concretely it can be traced from intent to implementation, including where the two diverged.
GitHub’s plan, published on 22 September 2025, listed six intended changes3:
- “Deprecate legacy classic tokens.”
- “Deprecate time-based one-time password (TOTP) 2FA, migrating users to FIDO-based 2FA.”
- “Limit granular tokens with publishing permissions to a shorter expiration.”
- “Set publishing access to disallow tokens by default, encouraging usage of trusted publishers or 2FA enforced local publishing.”
- “Remove the option to bypass 2FA for local package publishing.”
- “Expand eligible providers for trusted publishing.”
The plan said granular publish tokens “will have a limited lifetime of seven days”3. What actually shipped on 5 November 2025 was less aggressive. Creation of new classic tokens was disabled, and “All npm existing granular tokens with write permissions are now capped at a maximum 90-day lifetime”26. New write tokens “now enforce 2FA by default”, with a “Bypass 2FA” option retained for CI/CD26. The gap between seven days and ninety is itself informative. Ecosystems cannot move faster than the automation that depends on them.
Classic tokens were permanently revoked on 9 December 2025, after a postponement from the originally announced 19 November. Interactive sessions were cut to two-hour tokens, which proved too disruptive. npm staff wrote that “since last Friday the 12th we’ve extended the session to 12 hours”27.
2026 brought the more structural changes:
- CircleCI joined GitHub Actions and GitLab.com as a trusted-publishing provider11.
- Staged publishing (22 May 2026). A publish can now land in a queue “where a maintainer must explicitly approve it before it becomes installable”, and “A human maintainer with a 2FA challenge is required to approve a staged package before it is released to the registry”. It works with trusted publishing and needs npm CLI 11.15.0 or newer28. This finally puts a human, with a phishing-resistant factor, back into automated releases without making CI interactive.
- npm v12 and the bypass-2FA deprecation (8 July 2026). npm v12 became generally available and stopped running dependency lifecycle scripts by default: “Dependency lifecycle scripts (i.e.,
preinstall,install,postinstall) and implicitnode-gypbuilds no longer run unless explicitly allowed.”29 That is the exact execution path both Shai-Hulud waves used. The same post set a timeline for 2FA-bypass tokens. They lose sensitive account actions from early August 2026, and “2FA-bypass tokens will also lose the ability to publish directly”, with that change expected “around January 2027”. The advice is to “move automated publishing to trusted publishing (OIDC) or staged publishing with a human approval step, rather than a long-lived publish token”29. - Stage-only tokens (18 September 2026). For automation that cannot use OIDC, a token can now submit a version for review but cannot publish: “npm rejects direct
npm publishattempts with that token, even if you’ve configured it to bypass 2FA for automation.”30
Read in sequence, the direction is unambiguous. By early 2027 the only ways to get a version onto npm will be trusted publishing, or a human with 2FA approving it. The long-lived token that can publish on its own is being removed from the registry’s model.
PyPI’s numbers show adoption following the same curve without npm’s deprecation pressure. PyPI reported that in February 2024 “only about 10% of files were uploaded via Trusted Publishers”, rising to “over 25% of all files uploaded to PyPI” by October 202514. Its 2025 year-in-review reported “more than 50,000 projects are now using trusted publishing”, and that “17% of all uploads to PyPI in the last year included an attestation”31.
11. Verifying on the consumer side
Provenance that nobody checks is decoration. Both ecosystems now give consumers cheap ways to check.
On npm, npm audit signatures verifies the registry signatures of downloaded packages, and npm’s documentation notes that it “will also verify the provenance attestations of downloaded packages”32. The more interesting use is as a diff: if a package that has always shipped with provenance suddenly publishes a version without it, that version did not come from the usual trusted workflow. A worm publishing with a stolen token cannot produce provenance pointing at the maintainer’s repository, because it cannot obtain an OIDC token from the maintainer’s CI.
For your own artifacts (binaries, container images, tarballs) GitHub’s artifact attestations, generally available since June 2024 and “Powered by Sigstore”, provide the same mechanism outside a package registry33. Create them in the build with actions/attest-build-provenance and verify them at deploy time:
gh attestation verify ./dist/widget-linux-amd64 -o acme
The verification is the policy decision: this binary was built by a workflow in the acme organisation, recorded in a public log, and signed with a certificate that existed for ten minutes. If a deploy pipeline refuses artifacts that fail this check, a stolen credential cannot inject a binary into production, because a stolen credential cannot mint the OIDC identity behind the signature.
12. A migration checklist
For a team that publishes packages or release artifacts, in priority order:
- Inventory standing publish credentials. Search CI secret stores for
NPM_TOKEN,PYPI_API_TOKEN,TWINE_PASSWORD, and their equivalents. Search developer machines’~/.npmrcand~/.pypirc. Each one found is a Shai-Hulud target. - Configure a trusted publisher per package, pinned to the release workflow file and a protected environment, not merely the repository.
- Delete the old tokens and disallow token publishing. On npm, “Require two-factor authentication and disallow tokens”11. Ultralytics’ second wave is the reason this step is not optional23.
- Split build from publish. The job that runs dependency code must not hold
id-token: write. - Pin Actions to commit SHAs and remove
pull_request_targetand shared caches from anything that touches release. - Consider staged publishing on npm, so that a compromised workflow produces a pending version a human must approve rather than an installable one28.
- Verify provenance where you consume:
npm audit signaturesin CI,gh attestation verifyin deploy pipelines. - Upgrade to npm v12 or explicitly disable dependency lifecycle scripts on older versions, removing the install-time execution path the worms used29.
None of these steps requires buying anything, and most take an afternoon per repository.
Closing: credentials should be derived, not kept
Shai-Hulud was described at the time as a sophisticated attack, and its engineering was competent. But its central mechanism was almost trivially simple. It read a file that every maintainer keeps in the same place, containing a string that could do anything and never expired, and used it. The worm did not defeat npm’s security. It used the security model npm had: possession of a token is authority.
What the registries have built since is a different model. Authority to publish is derived, at the moment of use, from a verifiable claim about which build is running, and it expires before anyone could usefully steal it. Provenance turns that claim into something consumers can check after the fact, and a transparency log makes the check publicly auditable. It is the same shift this series has traced through DPoP for access tokens, SPIFFE for workloads, and short-lived certificates for TLS. Every one of them replaces a long-lived secret with a short-lived proof.
Trusted publishing is not a finished defence, and the Ultralytics post-mortem is the clearest evidence. It moves the target from the token to the build. The token was a static string that anyone could exfiltrate. The build is a pipeline you can pin, review, isolate, and gate behind a human. That is a far better thing to have to defend.
If your release process still depends on a token that a developer pasted into a settings page years ago, that token is the weakest point in your supply chain. Replace it with trusted publishing and delete it.
References
Kurmi, A., “Shai-Hulud: Self-Replicating Worm Compromises 500+ NPM Packages”, StepSecurity, 15 September 2025. https://www.stepsecurity.io/blog/ctrl-tinycolor-and-40-npm-packages-compromised ↩︎ ↩︎
CISA, “Widespread Supply Chain Compromise Impacting npm Ecosystem”, alert, 23 September 2025. https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem ↩︎ ↩︎ ↩︎
René-Corail, X., “Our plan for a more secure npm supply chain”, GitHub Blog, 22 September 2025. https://github.blog/security/supply-chain-security/our-plan-for-a-more-secure-npm-supply-chain/ ↩︎ ↩︎ ↩︎ ↩︎
Python Package Index, “Publishing to PyPI with a Trusted Publisher”, PyPI Docs. https://docs.pypi.org/trusted-publishers/ ↩︎ ↩︎
Ramati, H., Benmocha, G., and Aminov, D., “Widespread npm Supply Chain Attack: Breaking Down Impact & Scope Across Debug, Chalk, and Beyond”, Wiz Blog, 9 September 2025. https://www.wiz.io/blog/widespread-npm-supply-chain-attack-breaking-down-impact-scope-across-debug-chalk ↩︎ ↩︎ ↩︎
PyPI Blog, “Token Exfiltration Campaign via GitHub Actions Workflows”, 16 September 2025. https://blog.pypi.org/posts/2025-09-16-github-actions-token-exfiltration/ ↩︎
Moore, J., "‘Shai-Hulud’ Worm Compromises npm Ecosystem in Supply Chain Attack", Palo Alto Networks Unit 42, updated November 2025. https://unit42.paloaltonetworks.com/npm-supply-chain-attack/ ↩︎ ↩︎
Ingram, D., “Introducing ‘Trusted Publishers’”, PyPI Blog, 20 April 2023. https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ ↩︎ ↩︎
GitHub Changelog, “npm trusted publishing with OIDC is generally available”, 31 July 2025. https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ ↩︎ ↩︎ ↩︎
GitHub Docs, “OpenID Connect reference”. https://docs.github.com/en/actions/reference/security/oidc ↩︎ ↩︎ ↩︎ ↩︎
npm Docs, “Trusted publishing for npm packages”. https://docs.npmjs.com/trusted-publishers ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Python Package Index, “Publishing with a Trusted Publisher”, PyPI Docs. https://docs.pypi.org/trusted-publishers/using-a-publisher/ ↩︎ ↩︎ ↩︎
Jones, M., Nadalin, A., Campbell, B., Bradley, J., and Mortimore, C., “OAuth 2.0 Token Exchange”, IETF RFC 8693, January 2020. https://www.rfc-editor.org/rfc/rfc8693 ↩︎
PyPI Blog, “Trusted Publishing is popular, now for GitLab Self-Managed and Organizations”, 10 November 2025. https://blog.pypi.org/posts/2025-11-10-trusted-publishers-coming-to-orgs/ ↩︎ ↩︎
Newman, Z., Meyers, J. S., and Torres-Arias, S., “Sigstore: Software Signing for Everybody”, Proceedings of the 2022 ACM SIGSAC Conference on Computer and Communications Security (CCS ‘22), November 2022. https://doi.org/10.1145/3548606.3560596 ↩︎
DeHamer, B. and Harrison, P., “Introducing npm package provenance”, GitHub Blog, 19 April 2023. https://github.blog/security/supply-chain-security/introducing-npm-package-provenance/ ↩︎ ↩︎ ↩︎
Sigstore, “Certificate Authority Overview” (Fulcio), Sigstore Docs. https://docs.sigstore.dev/certificate_authority/overview/ ↩︎
Blauzvern, H., “Rekor v2 GA – Cheaper to run, simpler to maintain”, Sigstore Blog, 10 October 2025. https://blog.sigstore.dev/rekor-v2-ga/ ↩︎ ↩︎
Ingram, D., “PyPI now supports digital attestations”, PyPI Blog, 14 November 2024. https://blog.pypi.org/posts/2024-11-14-pypi-now-supports-digital-attestations/ ↩︎
SLSA Community, “Announcing SLSA v1.2”, 24 November 2025. https://slsa.dev/blog/2025/11/announce-slsa-v1.2 ↩︎
SLSA, “Build Track: Basics”, SLSA specification v1.2. https://slsa.dev/spec/v1.2/build-track-basics ↩︎ ↩︎ ↩︎
Torres-Arias, S., Afzali, H., Kuppusamy, T. K., Curtmola, R., and Cappos, J., “in-toto: Providing farm-to-table guarantees for bits and bytes”, 28th USENIX Security Symposium, 2019. https://www.usenix.org/conference/usenixsecurity19/presentation/torres-arias ↩︎
PyPI Blog, “Supply-chain attack analysis: Ultralytics”, 11 December 2024. https://blog.pypi.org/posts/2024-12-11-ultralytics-attack-analysis/ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
GitHub Advisory Database, “tj-actions/changed-files” (CVE-2025-30066), GHSA-mrrh-fwg8-r2c3, March 2025. https://github.com/advisories/GHSA-mrrh-fwg8-r2c3 ↩︎
CISA, “Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154)”, alert, 18 March 2025 (revised 26 March 2025). https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction ↩︎
GitHub Changelog, “npm security update: Classic token creation disabled and granular token changes”, 5 November 2025. https://github.blog/changelog/2025-11-05-npm-security-update-classic-token-creation-disabled-and-granular-token-changes/ ↩︎ ↩︎
npm staff (leobalter), “Update: Classic token removal moves to December 9, bundled with new CLI improvements”, GitHub Community discussion #179562, 13 November 2025 (updated December 2025). https://github.com/orgs/community/discussions/179562 ↩︎
GitHub Changelog, “Staged publishing and new install-time controls for npm”, 22 May 2026. https://github.blog/changelog/2026-05-22-staged-publishing-and-new-install-time-controls-for-npm/ ↩︎ ↩︎
GitHub Changelog, “npm install-time security and GAT bypass2fa deprecation”, 8 July 2026. https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/ ↩︎ ↩︎ ↩︎
GitHub Changelog, “Stage-only npm tokens for safer automation”, 18 September 2026. https://github.blog/changelog/2026-09-18-stage-only-npm-tokens-for-safer-automation/ ↩︎
Ingram, D., “PyPI in 2025: A Year in Review”, PyPI Blog, 31 December 2025. https://blog.pypi.org/posts/2025-12-31-pypi-2025-in-review/ ↩︎
npm Docs, “npm-audit”, npm CLI v11. https://docs.npmjs.com/cli/v11/commands/npm-audit ↩︎
GitHub Changelog, “Artifact Attestations is generally available”, 25 June 2024. https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/ ↩︎