DEV Community

Srinivas Kondepudi
Srinivas Kondepudi

Posted on Edited on

npm Trusted Publishing fails with a 404, and the 404 is lying to you

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. npm documents the requirement as npm CLI 11.5.1 or later, and Node 22.14.0 or higher.

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.

You will notice 11.5.0 has the file too, which is worth explaining rather than glossing over. The OIDC code first shipped in 11.5.0, and the only difference in 11.5.1 is a scoping fix in the same file:

-      let enableProvenance = false     // declared inside the inner block
+    let enableProvenance = false       // hoisted out
Enter fullscreen mode Exit fullscreen mode

In 11.5.0 the provenance-enabling branch sat inside an inner block, so it could not fire correctly. The token exchange itself is byte-identical between the two. That is why npm's documented floor is 11.5.1 rather than 11.5.0, and it is the version to pin. The distinction does not matter for the 404 in this post, because Node 20 gives you npm 10, which has nothing at all.

Why an unauthenticated publish returns 404

This is the part that wasted the most time.

The publish went out with no usable credential at all, and the registry answered 404 rather than 401 or 403. The explanation usually given is that answering 403 would confirm a package exists, letting anyone enumerate private package names by guessing, so the registry collapses "does not exist" and "you may not see this" into one response. I have not found npm stating that intent in those words, so take the reasoning as the community explanation and the behaviour as the observed fact.

The distinction that actually helps you debug is this one:

  • 403 usually means your credential arrived and the identity behind it is not allowed to publish this package. The familiar form is You do not have permission to publish "x". Are you logged in as the correct user?
  • 404 on a package you own usually means the registry never established who you are at all. The credential is missing, empty, or never sent.

So read '[email protected]' is not in this registry as a statement about your identity, not about your package name. That is the most useful reframing in this post. I spent a long time re-checking the package name, the scope, and the registry URL, when the message was really telling me the request was anonymous.

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

  1. Print npm --version in the job. npm 10 and below will not attempt OIDC at all, and 11.5.1 is npm's documented floor. This is the single highest-value check and it takes one line.
  2. Read a 404 on a package you own as an identity problem, not a naming one. A 403 usually means your credential arrived and was rejected; a 404 usually means it was never established.
  3. Do not treat a successful provenance step as proof that auth works. They are separate features.
  4. NODE_AUTH_TOKEN: XXXXX-XXXXX-XXXXX-XXXXX is a literal placeholder, not a masked secret.
  5. Only use --provenance if the source repo is public. Otherwise omit it and let npm decide.
  6. Check the Trusted Publisher's workflow filename matches the workflow that actually runs the publish.

The general lesson is about error messages that withhold information. The 404 was not a bug. It was the registry saying it did not know who I was, in the least specific way available to it, and I read it literally for far longer than I should have.

Top comments (0)