A trusted publish that fails on npm leaves exactly one honest line in the log, and it sits below the default log level:
npm verbose oidc Failed token exchange request with body message: OIDC token exchange error - package not found
On the terminal you get npm error code ENEEDAUTH, or npm error 404 Not Found - PUT https://registry.npmjs.org/<pkg>. Neither mentions OIDC. The package named in that 404 is usually sitting on the registry the entire time, published, downloadable, with a trusted publisher attached.
Short version: npm's exchange endpoint does two lookups behind one error string. It finds the package record, then matches your ID token against the stored trusted-publisher configuration. When the second match fails, the response still says "package not found". GitHub changed the shape of the sub claim in that token, so repositories created, renamed, or transferred after 15 July 2026 fail the match while the package sits right where you left it.
The damage is not a red build. Under release pressure the obvious move is to put NPM_TOKEN back into the workflow, and the control you adopted to get long-lived publish tokens out of CI comes back out of the box. Public pull requests doing exactly that, citing npm/cli issue #9969 by number, already exist. That reverses a chunk of the work behind SHA-pinning and the gaps it leaves open, for a reason the publishing team did not cause and cannot see from the job log.
#9969 was filed on 14 September 2026 against npm 11.19.0 and Node 24.20.0, and is still open with a Needs Triage label. The reporter's token carried sub: repo:isamrish@6524419/design-system-axi@1369171675:ref:refs/heads/..., and design-system-axi was already on the registry. The pattern of a clean upgrade hiding a broken publish runs through the rest of the npm 12 rollout too, but this one needs no upgrade at all. A repository rename is enough to join the affected population.
What does "OIDC token exchange error - package not found" actually check?
The CLI requests an ID token with audience npm:${new URL(registry).hostname}, so npm:registry.npmjs.org by default, then POSTs it to /-/npm/v1/oidc/token/exchange/package/<escapedPackageName>. The registry resolves the package, compares the token's claims against the publisher record stored for that package, and answers. Both failures come back wearing the same message, and the CLI relays the registry's error.body.message verbatim, so the misleading noun is the registry's, not the client's.
What changed underneath is the string being compared. GitHub's changelog of 23 April 2026 introduced immutable subject claims, which carry numeric owner and repository IDs after an @ delimiter: repo:octocat@123456/my-repo@456789:ref:refs/heads/main in place of repo:octocat/my-repo:ref:refs/heads/main. Per that changelog, every repository created after 15 July 2026 gets the new format automatically, as does any repository renamed or transferred after that date. Older repositories keep the legacy shape until someone opts in.
Why does the publish fail without ever saying OIDC failed?
Read lib/utils/oidc.js on the latest branch and the design is explicit. The function's own JSDoc says it "is intended to never throw, as it mutates the state of the opts and config objects on success." Every failure path takes return undefined and announces itself below the default level: a missing id-token: write permission at log.silly, a failed exchange at log.verbose, a response with no token at log.verbose. Nothing reaches warn or error. npm/cli issue #9923, filed 27 August 2026 against npm 12.0.2 and since closed, asked for exactly that promotion.
A credential helper that cannot fail loudly is a credential helper that cannot be monitored. The job log shows an authentication error with no indication that an authentication method was tried and rejected, which is the same debugging hole as "The runner no longer exists": exit 0 with the real cause unlogged. I now treat --loglevel=verbose on a publish step as a permanent setting rather than a debugging one.
Why do some jobs get ENEEDAUTH and others a 404?
oidc() is called inside #publish right after npmFetch.pickRegistry. The next two statements decide which error you see:
const creds = this.npm.config.getCredentialsByURI(registry)
const noCreds = !(creds.token || creds.username || (creds.certfile && creds.keyfile))
With nothing in .npmrc, noCreds is true and npm throws ENEEDAUTH with "This command requires you to be logged in to <registry>". With any credential present, publish proceeds and the registry rejects the PUT with a 404 about your package.
The trigger for the second branch is documented in npm/documentation issue #1960, open since 17 May 2026. actions/setup-node with registry-url writes //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} into .npmrc. npm's workspaces/config/lib/env-replace.js resolves ${VAR} for an undefined variable by returning the placeholder text unchanged, because the empty-string fallback needs the optional ? modifier (${VAR?}). A workflow with registry-url and no NODE_AUTH_TOKEN therefore authenticates with the literal string ${NODE_AUTH_TOKEN} and earns the 404. Drop registry-url and the same OIDC failure reappears as ENEEDAUTH, which at least names authentication. setup-node has form for shifting under a working job, as with node20 actions now executing on Node 24.
Why did PyPI take the same change without breaking?
Warehouse, PyPI's codebase, lists sub under __unchecked_claims__ and binds its GitHub publishers to repository, repository_owner, repository_owner_id, and job_workflow_ref, with environment optional (warehouse/oidc/models/github.py). None of those claims changed on 15 July 2026, including the numeric repository_owner_id that the immutable sub merely inlines into a longer string. The registry that verified a composite human-readable identifier broke. The registry that verified the components it was built from did not.
The fair objection is that sub is the claim an OIDC relying party is told to trust, and it packs owner, repo, ref, workflow, and environment into one comparable value, which is cheap and precise. That holds right up to the moment the provider reformats it. A publisher record keyed on separate claims survives a rename, a transfer, and a format migration, and it costs one extra comparison per field. I would take that trade on any authorization check against an identity provider I do not control.
Why the sub format moved at all
The @ delimiter is itself a patch. Boost Security Labs documented that GitHub's first implementation separated name from ID with a hyphen (repo:octocat-123456/my-repo-456789:...). Hyphens are legal in GitHub names, so an attacker could register an organisation literally called octocat-123456 and mint a colliding sub in the legacy format. François Proulx reported it through HackerOne (#3693338) on 24 April 2026; GitHub pulled the feature the same afternoon and republished it with @, a character no account name can contain (Boost Security Labs write-up). Downstream registries inherited a breaking format change whose motivation was a pre-hijack flaw in a fix for namespace recycling. Nobody in the chain did anything careless, and the publish still fails.
What to run before your next release
The exchange error has at least five causes and one message, so run these in order rather than guessing.
- Check the claim format first.
gh api repos/OWNER/REPO/actions/oidc/customization/subreturnsuse_default,use_immutable_subject, andsub_claim_prefix(REST docs). An@insub_claim_prefixmeans the repository already mints immutable subjects. Run it across every repo that publishes to npm and record the answer, including repos created before 15 July 2026 that were renamed or transferred after it. - Read the stored publisher.
npm trust list <pkg> --jsonshows what the registry will compare against. If no publisher is configured, or the workflow filename or environment differs by so much as the.ymlextension, you have a different bug with the same message. Decoding the ID token payload inside the job is the fallback when the API is unavailable; echo only the decoded payload, never the raw JWT. - Confirm the package exists. npm requires a published package before a publisher can be attached, and the docs point at a
0.0.0stub created withnpx setup-trusted-publishing. A monorepo package whosepackage.jsonsetsrepository.directoryis worth checking separately. - Pin npm above the floor. Trusted publishing needs 11.5.1 or newer on the runner. CircleCI SSH reruns have their own gap, tracked in npm/cli #10004.
- Make the failure visible. Publish with
--loglevel=verbosepermanently, or wrap the step so theoidclines land in the job log, and gate the release on evidence the exchange returned a token rather than on exit code alone. Until #9923's request ships, the only error you see is the wrong one. - Remove
registry-urlfromactions/setup-nodein any job publishing via OIDC. The placeholder it leaves in.npmrcturns a diagnosable ENEEDAUTH into a 404 naming your own package. - Treat a reinstated
NPM_TOKENas an incident artifact. Write the removal ticket when you add it, referencing #9969, or it stays for years.
The design lesson generalises past npm: bind authorization to component claims, not to a composite string a provider can reformat. Warehouse is the reference implementation here, and npm's current behaviour is the counterexample.
FAQ
Do I have to publish the package before trusted publishing works? Yes. npm requires an existing package before a trusted publisher can be attached, and the documented path is a 0.0.0 stub, which npx setup-trusted-publishing will create. That is one cause of "package not found" among several, which is why the gh api sub-claim check comes first rather than last.
Why do I get ENEEDAUTH instead of a 404? #publish checks getCredentialsByURI immediately after the OIDC attempt. With no credential in .npmrc you get ENEEDAUTH; with any credential, including the literal unresolved ${NODE_AUTH_TOKEN} string that setup-node writes, publish continues and the registry answers 404 on the PUT. Both outcomes can come from the same failed token exchange.
Can I check my repo's sub claim format without running a publish? gh api repos/OWNER/REPO/actions/oidc/customization/sub answers it from outside any workflow. An @ in sub_claim_prefix means immutable subjects are in effect. Decoding the ID token payload inside a job works too, and should never print the raw token.
Is putting NPM_TOKEN back a safe workaround? It restores the long-lived publish credential that trusted publishing existed to remove, on a package that an attacker reaching your CI can then publish. If you need it to ship today, scope it to one package, set the shortest expiry npm offers, and attach a removal ticket citing #9969, which remains open and untriaged as of early October 2026.
Which npm version does trusted publishing need? 11.5.1 is the documented floor, so pin the runner's npm rather than inheriting whatever setup-node resolves. #9969 was reported on 11.19.0 with Node 24.20.0, so a newer npm does not avoid the sub mismatch; only the publisher configuration or GitHub's claim format can resolve that.
Comments
Be the first to comment.