The Problem It Solves Link to heading

Last month I wrote about the supply chain attack problem and why the open source trust model is structurally broken. One of the core vulnerabilities I described is the compromise of a maintainer’s credentials - an attacker gains access to an npm token, publishes a malicious version, and the registry obediently distributes it to anyone running npm install.

There is a concrete mitigation for exactly this attack vector, and it has been available for a while now. npm Trusted Publishers uses OIDC to eliminate long-lived publish tokens entirely. No token stored in GitHub Secrets. No token that can be stolen, leaked, or rotated after an incident. The npm registry validates the publish request directly against the GitHub Actions workflow that triggered it. If the workflow matches the configured Trusted Publisher, it gets a temporary credential that lives for the duration of that single CI run and nothing else.

That is the theory. The practice is where things got interesting.

What Trusted Publishers Actually Does Link to heading

The mechanism is worth understanding before you try to configure it, because the failure modes only make sense once you understand the happy path.

When you publish with --provenance from a GitHub Actions workflow that has id-token: write permissions, the GitHub runner injects two environment variables: ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. These let any process in the workflow request a short-lived OIDC JWT from GitHub’s identity provider.

On the npm side, you configure a Trusted Publisher on the package’s settings page at npmjs.com. You specify the organisation, repository, and workflow file. npm stores this configuration and associates it with your package.

When npm 11’s publish command runs in a GitHub Actions environment, it:

  1. Detects the ACTIONS_ID_TOKEN_REQUEST_URL environment variable.
  2. Requests an OIDC token from GitHub with the audience npm:registry.npmjs.org.
  3. POSTs that token to /-/npm/v1/oidc/token/exchange/package/<your-package> on the registry.
  4. The registry validates the JWT’s claims against your Trusted Publisher configuration - checking the organisation, repository, and workflow file.
  5. On success, it issues a temporary access token scoped to that single publish operation.
  6. npm uses that token to complete the upload.

The entire process takes a few hundred milliseconds and produces a publish with a cryptographic provenance attestation that links the package back to the specific commit, workflow run, and repository that produced it.

No stored secrets. No rotation policy. No incident response checklist for a leaked token. What this eliminates is the attack where someone exfiltrates an npm publish token and uses it directly against the registry - no source repository access required. Without push access to the specific GitHub repository and the ability to trigger the configured workflow, an attacker holding only a stolen token cannot satisfy the OIDC claims check. That is a meaningfully higher bar than stealing a token from ~/.npmrc or a CI secrets store.

It is worth being equally clear about what it does not eliminate. A fully compromised GitHub account still grants push access to the repository, which means the release workflow can be triggered by the attacker directly. Trusted Publishers narrows the attack surface to GitHub account security, which makes strong GitHub MFA - ideally passkeys - the complementary control that actually completes the picture. Keyless publishing and account security are not alternatives; they work together.

How Hard Could It Be? Link to heading

I set this up as part of releasing a new version of an npm package for an open source project. The npm documentation makes it look like three lines of YAML. What followed was several hours and about a dozen failed CI runs. I will save you the journey and go directly to what actually matters.

Trap 1: actions/setup-node Injects a Token You Do Not Want Link to heading

The standard advice for publishing to npm from GitHub Actions is to use actions/setup-node@v4 with registry-url: "https://registry.npmjs.org". Do not do this for Trusted Publishers publishing.

When you set registry-url, the action exports GITHUB_TOKEN as NODE_AUTH_TOKEN into the runner environment for all subsequent steps, and writes //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} into a temporary .npmrc file. npm reads NODE_AUTH_TOKEN directly as an authentication credential - not just as a template variable in the .npmrc, but as a first-class auth token.

This is a problem because GITHUB_TOKEN is not a valid npm publish credential. It will get a 404 from the registry. But more importantly for Trusted Publishers, the presence of any configured _authToken causes npm to use that token and skip the OIDC exchange entirely. The OIDC path is only taken when npm finds no other authentication.

The obvious fix is to strip the token: remove the _authToken line from the .npmrc with sed and unset NODE_AUTH_TOKEN in the publish step’s shell script. In theory this works. In practice, unset did not prevent npm from using the token in the environment I was working in. The credential was read directly from the environment variable regardless of whether it appeared in any config file.

The actual fix is simpler: do not set registry-url. Without it, the action does not inject anything. The publish step can write its own minimal .npmrc if needed, or in the right environment, it does not need one at all.

Trap 2: The OIDC Exchange Does Not Exist in npm 10 Link to heading

This is the one that cost the most time.

Node.js 22 ships with npm 10.x. That version does not have lib/utils/oidc.js - the module that implements the OIDC token exchange described above. That module was reworked in npm 11. Without it, the publish command reaches an authentication check with no configured credentials, returns ENEEDAUTH, and exits.

The error is exactly the same whether the OIDC exchange never fires or whether it fires and fails. There is no indication in the default output that OIDC was attempted. Running with --loglevel verbose will tell you what is happening: if you see the oidc family of log messages, the exchange is being attempted. If you do not see them, you are almost certainly running npm 10.

The verbose stack trace showed the error coming from publish.js:149 via Publish.exec. In npm 11, there is an await oidc(...) call at approximately that line position - before the noCreds check. In npm 10, that call does not exist. The noCreds check fires immediately against an empty credential set and throws.

Upgrading npm 10 to npm 11 using npm itself on the affected runner image is not straightforward. The npm install -g npm@latest command fails with MODULE_NOT_FOUND: Cannot find module 'promise-retry' deep in npm 10.9.7’s arborist dependency tree. The runner image’s bundled npm is self-broken for this specific operation.

Trap 3: Project-Level .npmrc Is Ignored in Workspace Packages Link to heading

One attempted fix was to write a minimal .npmrc containing only registry=https://registry.npmjs.org/ into the package directory before publishing. This seems reasonable. It is not.

The warning message makes this clear: npm warn config ignoring workspace config at .../packages/server/.npmrc. In a monorepo with a root package.json that includes a workspaces field, npm treats any .npmrc in a workspace package’s directory as a workspace-scoped config and skips it.

The correct location is ~/.npmrc - the user-level config file, which is always read.

The Fix: Node 24 Link to heading

Node.js 24 ships with npm 11, which has the OIDC exchange code. The fix is to use node-version: "24" in the workflow and not set registry-url.

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "24"
          # No registry-url: setting it injects GITHUB_TOKEN as NODE_AUTH_TOKEN
          # into the runner environment, which blocks the OIDC path in npm 11.

      - name: Publish
        working-directory: packages/my-package
        run: npm publish --provenance --access public

That is it. No token. No secret. Version 11 handles the rest.

The id-token: write permission must be on the job, not just the workflow. Both work in principle, but putting it on the job is explicit and unambiguous about which job gets the OIDC capability.

The --no-workspaces flag can help if npm keeps trying to publish all workspace packages instead of the one in the current directory. It is not strictly necessary if you use working-directory to navigate into the specific package first, but it prevents unexpected behaviour.

Why This Is Going to Become Mandatory Link to heading

Every supply chain attack that involves a compromised npm publish token is an argument for Trusted Publishers. The npm registry already supports it. GitHub Actions already supports it. The infrastructure exists. The only thing missing is adoption.

The direction of travel in software security is clear: stored secrets are being eliminated wherever possible. OIDC has already replaced long-lived cloud provider credentials in most enterprise CI environments - the AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pair that used to live in every CI secrets store has largely been replaced by AWS IAM role assumption via OIDC. The same transition is happening for npm.

It is a question of when, not if, Trusted Publishers publishing becomes the expected baseline for packages published to the npm registry. Several factors are accelerating this:

  • Package registries are under increasing pressure to provide verifiable provenance for published packages. The provenance attestation that comes with --provenance is a concrete, auditable record that links a published package to the specific source commit and CI run that produced it. This is exactly what SBOM requirements and software supply chain security frameworks are asking for.
  • The registry has already signalled the direction: Trusted Publishers and --provenance are the documented recommended publishing approach. The infrastructure investment is there.
  • Enterprise security policies are increasingly requiring that CI pipelines contain no long-lived secrets with external publish access. Trusted Publishers is the only mechanism that satisfies that requirement without a complex credential rotation programme.

Getting ahead of this now means one less fire drill when your organisation’s security team audits the CI pipeline and finds a long-lived npm token in the secrets store. It also means your published packages come with cryptographic provenance from day one, which will increasingly matter as the tooling for verifying that provenance matures.

The Working Checklist Link to heading

If you are setting this up on a package you maintain:

  1. Go to your package’s page on npmjs.com, navigate to Settings, and add a Trusted Publisher. Set the organisation, repository, and workflow filename. Leave the environment field blank unless your workflow uses a GitHub deployment environment.

  2. Update your GitHub Actions release workflow to use Node.js 24 (node-version: "24") and add id-token: write to the job’s permissions block.

  3. Remove registry-url from your actions/setup-node step if you have it.

  4. Change your publish command to npm publish --provenance --access public.

  5. Do not set NODE_AUTH_TOKEN in the workflow. Do not write _authToken into any .npmrc. The whole point is that there is no token.

  6. Trigger a release and watch the npm registry page for your package. A successful publish will show a provenance badge linking back to the GitHub Actions run.

  7. Once the provenance-attested publish is confirmed, go back to your package’s Settings page on npmjs.com and disable token-based publishing. Under the Access settings, you can require that all future publishes go through a Trusted Publisher and revoke any existing publish tokens. This closes the fallback path: a stolen npm token can no longer be used to publish directly to the registry if token publishing is disabled at the package level.

The first time through, add --loglevel verbose to the publish command so you can see whether npm is attempting the OIDC exchange. Look for log lines prefixed with npm verbose oidc. If you do not see them, check the Node version first.

A Note on the Manual Fallback Link to heading

If you are stuck on Node 22 and cannot upgrade, there is a working manual approach: request the GitHub OIDC token yourself with curl, POST it to /-/npm/v1/oidc/token/exchange/package/<your-package> to get a temporary npm token, write that to ~/.npmrc, and then publish normally. This is exactly what npm 11’s oidc.js does under the hood. It is verbose but it works, and it gives you full visibility into each step of the exchange.

I would not recommend it as a long-term approach. It hardcodes the exchange endpoint, requires shell scripting that obscures what is happening, and is exactly the kind of thing that will break silently when the registry changes its API. Upgrade to Node 24 and let npm handle it.

The Tanstack Compromise Link to heading

In May 2026, attackers published 84 malicious versions across 42 TanStack packages - despite the project using Trusted Publishers. The full postmortem is instructive reading; the attack demonstrates precisely what it means for Trusted Publishers to close one attack vector while leaving others open.

The OIDC mechanism itself was not broken. The attack exploited the workflow architecture surrounding it through a three-step chain.

Trust boundary violation via pull_request_target. The project’s bundle-size.yml workflow used the pull_request_target trigger, which runs fork-supplied code with base repository permissions. An attacker submitted a PR that caused the workflow to execute malicious build scripts from their fork.

Cache poisoning across workflow contexts. Those scripts wrote a poisoned pnpm store to a shared GitHub Actions cache, with a key crafted to match what the production release.yml workflow would restore. Cache writes in GitHub Actions are not gated by workflow permissions; a cache entry written by an untrusted PR run is available to any subsequent workflow run in the repository that matches the key. The PR and the release workflow shared cache scope, and the attacker used that to bridge the trust boundary between them.

OIDC token extraction from process memory. When the legitimate release workflow ran on the main branch, it restored the poisoned cache. The attacker’s code was now executing in a trusted context. It located the GitHub Actions Runner.Worker process via /proc/*/cmdline, read the lazily-minted OIDC token directly from process memory via /proc/<pid>/mem, and used it to POST the malicious packages directly to the npm registry - bypassing the workflow’s own publish step entirely.

The stored credentials were never compromised. The attack constructed a trusted execution environment and extracted credentials from it at runtime.

Three controls would have broken this chain before the OIDC token was ever in play:

  • Avoid pull_request_target for workflows that execute fork-supplied code. If write-back to the PR is required, separate it: use pull_request for untrusted builds and workflow_run for the privileged write-back step. At minimum, add a repository_owner guard - if: github.event.pull_request.head.repo.owner.login == 'tanstack' - to block execution for external forks.
  • Namespace cache keys by workflow context. Prefixing cache keys with the triggering event type - for example, ${{ github.event_name }}-pnpm-store-... - prevents a cache written by an untrusted PR workflow from being restored by a release workflow on main.
  • Pin actions to full commit SHAs rather than floating version tags such as @v5.

Trusted Publishers removed the stolen-token path. This attack went around the credential layer entirely by poisoning the trusted execution environment before credentials were minted. Both problems are real, and solving one does not address the other.

The Broader Point Link to heading

I wrote last month that the trust model underpinning package distribution is broken. Trusted Publishers does not fix the model - it closes one specific attack vector. A compromised maintainer who still has push access to the repository can still publish a malicious version. Prompt injection attacks against AI coding agents still work. postinstall hooks are still a problem unless you are using a package manager that disables them.

But removing long-lived publish tokens from the equation is not nothing. It eliminates the class of attacks where an attacker exfiltrates a token from a secrets store, a developer’s machine, or a leaked environment variable and publishes directly to the registry without any access to the source repository. That is a real attack pattern and Trusted Publishers makes it meaningfully harder.

Taking the hour to set it up properly - even the hour that turned into several hours because of npm 10 versus npm 11 - is worth it. Better to fight with a CI pipeline now than to explain to users later why a token you did not know was compromised was used to publish something they installed.