npm is winding down 2FA-bypass granular access tokens. Since 31 July 2026 they can no longer perform account, organisation, or package management operations, which includes editing trusted publishing configuration. In January 2027 they lose direct publish as well, leaving them able to read private packages and stage a publish that a maintainer then approves with 2FA.
The replacement is OIDC Trusted Publishing, where you register a GitHub repo and workflow on npmjs.com and the workflow authenticates with a short-lived token instead of a stored secret.
I migrated. The release workflow then failed four times in a row, and the error message pointed nowhere near the actual problem.
This is the writeup I wanted to find while debugging it.
The symptom
npm notice publish Signed provenance statement with source and
build information from GitHub Actions
npm notice publish Provenance statement published to transparency log:
https://search.sigstore.dev/?logIndex=2685306213
npm error code E404
npm error 404 Not Found - PUT https://registry.npmjs.org/chron-mcp - Not found
npm error 404 '[email protected]' is not in this registry.
Enter fullscreen mode Exit fullscreen mode
The package exists. It has published versions. I own it. And the registry says it is not there.
Two things in that output are actively misleading, and I chased both.
Red herring one: the provenance step succeeds
Look at the order. Provenance signing works. It reaches sigstore, signs, and posts to the public transparency log. That requires the GitHub Actions OIDC token, so id-token: write is clearly working.
It is very natural to conclude that OIDC is fine and the problem is something else.
It is not the same feature. Provenance signing uses the OIDC id-token to sign an attestation with sigstore. Trusted Publishing uses the OIDC id-token to exchange for a registry auth token. Different mechanisms, different code paths, introduced in different npm versions. One can work perfectly while the other does not exist.
Red herring two: a token that looks leaked
The step environment shows this:
env:
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/.npmrc
NODE_AUTH_TOKEN: XXXXX-XXXXX-XXXXX-XXXXX
Enter fullscreen mode Exit fullscreen mode
That looks like a masked secret. My first theory was that a stale NPM_TOKEN was being injected and overriding OIDC, so I removed it from the workflow and added unset NODE_AUTH_TOKEN.
It made no difference, because that value is not a secret at all. GitHub is not masking anything. Those are real X characters.
actions/setup-node@v4 writes an .npmrc containing //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}, and then exports a dummy value when you have not supplied one, so that npm does not warn about a missing variable. From src/authutil.ts on the v4 tag:
// Export empty node_auth_token if didn't exist so npm doesn't complain
// about not being able to find it
core.exportVariable(
'NODE_AUTH_TOKEN',
process.env.NODE_AUTH_TOKEN || 'XXXXX-XXXXX-XXXXX-XXXXX'
);
Enter fullscreen mode Exit fullscreen mode
Worth knowing that this is version-specific. On main the action has changed to only export the variable when the user actually provided one, so if you are on a newer major you may not see this at all. If you are pinned to v4, you will, and it looks exactly like a leaked credential.
For completeness, a pre-set token would not block OIDC anyway. In lib/commands/publish.js, oidc() is called before credentials are read, and it overwrites the auth token directly:
await oidc({ packageName: manifest.name, registry, opts, config: this.npm.config })
const creds = this.npm.config.getCredentialsByURI(registry)
Enter fullscreen mode Exit fullscreen mode
The actual cause
The workflow pinned Node 20:
- uses: actions/setup-node@v4
with:
node-version: 20
Enter fullscreen mode Exit fullscreen mode
Node 20 bundles npm 10.8.2. OIDC Trusted Publishing landed in npm 11.5.1.
npm 10 does not have a partial or broken implementation. It has none at all. You can verify this without reading a changelog:
for v in 10.8.2 11.5.0 11.5.1 11.19.0; do
npm pack npm@$v --silent >/dev/null
mkdir -p ex-$v && tar xzf npm-$v.tgz -C ex-$v
echo "npm $v -> $(ls ex-$v/package/lib/utils/oidc.js 2>/dev/null || echo 'NO OIDC')"
done
Enter fullscreen mode Exit fullscreen mode
npm 10.8.2 -> NO OIDC
npm 11.5.0 -> ex-11.5.0/package/lib/utils/oidc.js
npm 11.5.1 -> ex-11.5.1/package/lib/utils/oidc.js
npm 11.19.0 -> ex-11.19.0/package/lib/utils/oidc.js
Enter fullscreen mode Exit fullscreen mode
So npm 10 never attempts the token exchange. It falls back to the _authToken in the generated .npmrc, which resolves to nothing useful, and publishes unauthenticated.
Why an unauthenticated publish returns 404
This is the part that wasted the most time. An unauthorised write to an existing package returns 404, not 403.
The registry does this deliberately. Returning 403 on a package you cannot write to would confirm that the package exists, which leaks the existence of private packages to anyone who can guess a name. So npm returns 404 for both “does not exist” and “exists but you may not touch it”.
Which means '[email protected]' is not in this registry should be read as “the registry does not believe you are allowed to know about this package”, and on a package you own, that almost always means an authentication problem rather than a naming one.
The fix, part one
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Ensure npm supports OIDC trusted publishing
run: |
npm install -g npm@^11.5.1
npm --version
Enter fullscreen mode Exit fullscreen mode
Node 24 bundles npm 11.19.0, which is already sufficient. The explicit global install is a guard: Node 24.0.0 shipped npm 11.3.0, which is below the threshold, so pinning the major alone is not a guarantee. Printing npm --version into the log means the next person to debug this can rule it out in one glance.
The fix, part two, which the first fix revealed
With authentication working, the error changed. That is progress, even when it is still red:
npm error code E422
npm error 422 Unprocessable Entity - PUT https://registry.npmjs.org/chron-mcp -
Error verifying sigstore provenance bundle: Unsupported GitHub Actions source
repository visibility: "private". Only public source repositories are
supported when publishing with provenance.
Enter fullscreen mode Exit fullscreen mode
Provenance requires a public source repository. The repo publishing this package is private, so provenance was never achievable, and --provenance had been wrong from the first commit. It stayed invisible because the auth failure happened first.
The fix is to drop the flag rather than to add --no-provenance. From lib/utils/oidc.js:
const isDefaultProvenance = config.isDefault('provenance')
if (isDefaultProvenance && !ciInfo.CIRCLE) {
const payload = JSON.parse(/* decoded id-token */)
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
const visibility = await libaccess.getVisibility(packageName, opts)
if (visibility?.public) {
opts.provenance = true
}
}
}
Enter fullscreen mode Exit fullscreen mode
npm auto-enables provenance only when the flag was left at its default and repository_visibility is "public". Passing --provenance explicitly sets isDefault to false and forces it on regardless. Omitting it lets npm make the correct decision on its own.
- npm publish --access public --provenance --ignore-scripts
+ npm publish --access public --ignore-scripts
Enter fullscreen mode Exit fullscreen mode
Green on the next run.
The working workflow
permissions:
contents: read
id-token: write # required for OIDC
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Ensure npm supports OIDC trusted publishing
run: |
npm install -g npm@^11.5.1
npm --version
- run: npm ci
- run: npm test
- name: Publish
run: npm publish --access public --ignore-scripts
Enter fullscreen mode Exit fullscreen mode
No NODE_AUTH_TOKEN. No NPM_TOKEN secret. The Trusted Publisher is configured on npmjs.com under the package’s Settings, pointing at the repo and the workflow filename, and both have to match exactly.
Checklist if you hit this
-
Print
npm --versionin the job. Anything below 11.5.1 means OIDC is not even being attempted. This is the single highest-value check and it takes one line. - Read a 404 on your own package as an auth failure. The registry masks 403 as 404 on purpose.
- Do not treat a successful provenance step as proof that auth works. They are separate features.
-
NODE_AUTH_TOKEN: XXXXX-XXXXX-XXXXX-XXXXXis a literal placeholder, not a masked secret. -
Only use
--provenanceif the source repo is public. Otherwise omit it and let npm decide. - Check the Trusted Publisher’s workflow filename matches the workflow that actually runs the publish.
The general lesson is the one about error messages that are deliberately vague for good security reasons. The 404 was not a bug and not a bad message. It was withholding information on purpose, and I read it literally for far longer than I should have.