npm Trusted Publishing
npm trusted publishing lets a CI job publish a package by exchanging its OIDC identity token for a short-lived registry token, with no stored npm secret.
Also known as trusted publishers, OIDC publishing, npm OIDC publishing, PyPI trusted publishing, RubyGems trusted publishing, crates.io trusted publishing, tokenless publishing
npm trusted publishing lets CI publish packages without a stored npm token. The CI job requests an OpenID Connect (OIDC) identity token, and the registry exchanges it for a short-lived publish credential if the repository and workflow match a registered configuration. It removes the long-lived token that worms steal. PyPI, RubyGems, and crates.io use the same model.
The model goes by several names: PyPI calls its version Trusted Publishers, and many maintainers just say OIDC publishing. The mechanism is the same everywhere, and so is its most important property: trusted publishing authenticates a CI job, not a person and not a commit. Any code that executes inside a job holding the OIDC grant can mint the same credential the release step would. In May 2026 the TanStack compromise published 84 malicious versions through the project's legitimate trusted publisher without stealing any npm token. Our position: adopt trusted publishing, but configure it stage-only so a human approves every release with 2FA, and disallow token publishing. If you consume packages, read a trusted-publisher badge as a statement about where a package was built, never about what is inside it.
What is npm trusted publishing?
#npm trusted publishing replaces a stored publish token with a trust relationship between a package and a CI workflow. The maintainer tells npmjs.com, once, which repository and workflow file may publish the package. From then on, that workflow proves its identity with a token signed by the CI provider, and npm issues a credential that works for that one package and expires on its own.
PyPI shipped the design first, and the OpenSSF Securing Software Repositories Working Group later turned it into guidance for every registry. PyPI launched Trusted Publishers in April 2023, RubyGems followed in December 2023, and crates.io added trusted publishing in July 2025. npm's trusted publishing launch came on July 31, 2025, with GitHub Actions and GitLab CI/CD support and a minimum of npm CLI 11.5.1.
The problem it targets is the long-lived API token. A classic npm token sat in a CI secret store or a ~/.npmrc file for years and worked from any machine. That made it a prime target, and 2025 showed what one stolen token is worth. In the Nx compromise in August 2025, a bash injection in a pull_request_target workflow let attackers trigger the publish workflow and send its npm token to a webhook. Weeks later the first Shai-Hulud worm ran TruffleHog on infected hosts, harvested npm tokens, and used them to republish itself into other packages the victim maintained. GitHub's response was to revoke every classic npm token on December 9, 2025 and point maintainers at trusted publishing.
PyPI's numbers show how far the model has spread. About a quarter of all files uploaded to PyPI in October 2025 came through Trusted Publishing, up from roughly 10 percent in early 2024, and more than 45,000 projects had enabled it, according to PyPI's figures.
How does trusted publishing work with GitHub Actions?
#Trusted publishing with GitHub Actions is a token exchange between three parties: the runner, GitHub's OIDC provider, and the registry. It helps to see what each piece normally does before looking at where it breaks.
A job declares permissions: id-token: write. GitHub then puts two environment variables on the runner: ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN. Together they let code in the job ask GitHub for a JSON Web Token (JWT) that GitHub signs with its own key. The JWT carries claims about the run: repository, repository_id, repository_owner_id, job_workflow_ref, ref, event_name, and environment. The aud (audience) claim names the service the token is for, so a token minted for npm cannot be replayed against AWS.
The npm CLI does the rest. This is the exchange, trimmed from lib/utils/oidc.js in npm 12.2.1:
const audience = `npm:${new URL(registry).hostname}` // "npm:registry.npmjs.org"
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
url.searchParams.append('audience', audience)
const { value: idToken } = await (await fetch(url.href, {
headers: { Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
})).json()
const escapedPackageName = npa(packageName).escapedName
const response = await npmFetch.json(
new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry),
{ ...opts, [authTokenKey]: idToken, method: 'POST' })
setOidcToken({ config, opts, authTokenKey, token: response.token })The registry verifies the JWT's signature against GitHub's published keys. It then compares the claims with the package's trusted publisher configuration: owner and repository, workflow filename, an optional GitHub environment, and the allowed actions. On a match it returns a short-lived publish token for that package. The exchange endpoint is public, and it rejects anything else:
$ curl -s -X POST -H "Authorization: Bearer not-a-jwt" \
https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/left-pad
{"message":"OIDC token exchange error - unauthorized"}Provenance rides on a second token. For a public package built from a public repository, the CLI asks GitHub for another JWT with audience sigstore, trades it at Sigstore's Fulcio certificate authority for a short-lived signing certificate, signs a provenance statement, and records it in the Rekor transparency log. The tarball and the attestations then go to the registry together.
The figure shows a trusted publish from GitHub Actions to npm in seven steps. (1) The release job, which has id-token: write, calls ACTIONS_ID_TOKEN_REQUEST_URL with audience=npm:registry.npmjs.org. (2) GitHub's OIDC provider returns a signed JWT carrying repository, workflow_ref, repository_id, and event_name. (3) The job POSTs that JWT as a bearer token to /-/npm/v1/oidc/token/exchange/package/<name>. (4) npm verifies the signature and matches owner and repository, workflow file, optional environment, and allowed actions against the package's configuration. (5) npm returns a short-lived publish token. (6) The job requests a second JWT with audience sigstore, gets a signing certificate from Fulcio, and logs the provenance signature in Rekor. (7) The job PUTs the tarball and attestations to npm, which either publishes the version or, for a staged publish, holds it until a maintainer approves it with 2FA.
PyPI runs the same protocol with different endpoints, and both are live:
$ curl -s https://pypi.org/_/oidc/audience
{"audience":"pypi"}
$ curl -s -X POST https://pypi.org/_/oidc/mint-token -d '{"token":"not-a-jwt"}'
{"errors":[{"code":"invalid-payload","description":"malformed JWT"}],"message":"Token request failed"}A PyPI token minted this way expires no more than 15 minutes after the OIDC flow that authorized it, per PyPI's security model. npm's documentation describes its tokens only as short-lived and workflow-specific and does not publish a lifetime.
As of October 2026, npm's trusted publisher documentation lists three supported providers: GitHub Actions on GitHub-hosted runners, GitLab CI/CD on GitLab.com shared runners, and CircleCI cloud. Self-hosted runners are not supported. Publishing requires npm CLI 11.5.1 or later and Node.js 22.14.0 or later. A package can have up to 10 trusted publisher configurations.
The registry record of a trusted publish
#A trusted publish leaves a machine-readable record in the package document, the JSON the registry serves for every package. _npmUser names "GitHub Actions" instead of a person and carries a trustedPublisher object. When provenance was generated, dist.attestations points to the signed statements. When the release went through staged publishing, an approver object names the npm account that approved it. npm's staged publishing documentation does not describe that field as of October 2026, so we infer its meaning from where it appears. Here is pnpm's latest release, with email addresses removed:
$ curl -s https://registry.npmjs.org/pnpm | jq '.versions["12.16.1"] |
{_npmUser: (._npmUser | del(.email, .approver.email)),
provenance: .dist.attestations.provenance}'
{
"_npmUser": {
"name": "GitHub Actions",
"approver": { "name": "zkochan" },
"trustedPublisher": {
"id": "github",
"oidcConfigId": "ab8ea4e6-5e14-45ea-9aad-652283679069"
}
},
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
}The provenance attestation is an in-toto statement wrapped in a DSSE envelope. Decoding its SLSA provenance predicate shows exactly which workflow, event, and commit produced the tarball:
$ curl -s https://registry.npmjs.org/-/npm/v1/attestations/pnpm@12.16.1 \
| jq -r '.attestations[] | select(.predicateType=="https://slsa.dev/provenance/v1")
| .bundle.dsseEnvelope.payload' | base64 -d \
| jq '.predicate.buildDefinition | {workflow: .externalParameters.workflow,
event: .internalParameters.github.event_name,
commit: .resolvedDependencies[0].digest.gitCommit}'
{
"workflow": {
"ref": "refs/tags/v12.16.1",
"repository": "https://github.com/pnpm/pnpm",
"path": ".github/workflows/release.yml"
},
"event": "push",
"commit": "c7b46aedef7b81c07816cb551187449a3dd81792"
}We ran the same check on the latest release of 13 widely used packages on October 28, 2026. Eight were published through a trusted publisher, three of those carried an approver, and one trusted-published release had no provenance at all.
| Package | Latest version | Published by | Approver | Provenance |
|---|---|---|---|---|
| pnpm | 12.16.1 | Trusted publisher | Yes | Yes |
| zod | 4.6.7 | Trusted publisher | Yes | Yes |
| express | 5.3.0 | Trusted publisher | Yes | Yes |
| vite | 8.3.5 | Trusted publisher | No | Yes |
| vue | 3.5.44 | Trusted publisher | No | Yes |
| nx | 24.0.2 | Trusted publisher | No | Yes |
| @biomejs/biome | 2.6.3 | Trusted publisher | No | Yes |
| semver | 7.8.5 | Trusted publisher | No | Yes |
| prettier | 3.9.10 | Trusted publisher | No | No |
| npm | 12.2.1 | Maintainer account | No | No |
| typescript | 7.0.2 | Service account | No | No |
| @angular/core | 22.2.3 | Service account | No | No |
| eslint | 10.13.0 | Service account | No | No |
Two points follow. Provenance is not automatic: npm generates it only for public packages built from public repositories, and maintainers can turn it off with provenance=false. And none of these fields says anything about what the tarball contains. They record the path a release took, which is exactly what an attacker inside that path inherits.
The trust boundary is the job, not the publish step
#Trusted publishing moves the publish credential from a secret into the identity of a CI job. Every step in that job can reach the identity. The ACTIONS_ID_TOKEN_REQUEST_* variables are runner environment variables, and GitHub's documentation does not restrict them to one step. Dependency installs, build plugins, test runners, and cached binaries all execute with the same OIDC grant as npm publish. A registry that checks "is this the release workflow of TanStack/router?" cannot tell a release step from a malicious test helper running inside that workflow.
TanStack's postmortem reconstructs exactly that. The attack was part of the Mini Shai-Hulud campaign:
- On May 10, 2026, the attacker forked TanStack/router and committed a roughly 30,000-line payload under a forged
claude@users.noreply.github.comidentity. - On May 11, a pull request triggered
bundle-size.yml, apull_request_targetworkflow that checked out the fork's merge ref and built it in the base repository's context. - At 11:29 UTC that job saved a 1.1 GB pnpm store to the GitHub Actions cache under a key scoped to
main. The key matched whatrelease.ymlcomputes frompnpm-lock.yaml. The postmortem notes that cache writes bypass the job'spermissions:block. - At 19:15 UTC two release runs restored the poisoned cache. The release job held
id-token: writebecause trusted publishing needs it. - Attacker binaries from the cache found the
Runner.Workerprocess through/proc/*/cmdline, read its memory through/proc/<pid>/mem, and extracted the OIDC token. The postmortem says the technique matches the March 2025 tj-actions/changed-files compromise. - The binaries POSTed directly to the npm registry. The workflow's own publish step never ran, because the tests failed.
The figure shows TanStack's release job on May 11, 2026. The job has permissions: id-token: write and four steps. Step 1 checks out clean source at main. Step 2 restores a pnpm cache entry that a fork pull request's job had saved about eight hours earlier under a key scoped to main. Step 3, build and test, runs an attacker binary from that cache, which finds Runner.Worker, reads /proc/<pid>/mem, and extracts the OIDC token. Step 4, npm publish, is skipped because tests failed and never runs. An arrow from step 3 shows the binary POSTing to the npm registry, where the trusted publisher check for TanStack/router release.yml passes and 42 packages receive 84 versions in two bursts, at 19:20:39 and 19:26:14 UTC. A bracket spanning all four steps notes that every step shares the job's OIDC grant.
The registry still carries the evidence. npm removed the malicious tarballs, but the package document keeps their publish times:
$ curl -s https://registry.npmjs.org/@tanstack%2fhistory | jq '{
time: (.time | with_entries(select(.key|test("^1\\.161\\.(9|12)$")))),
versions: [.versions | keys[] | select(test("^1\\.161\\.(9|12)$"))]}'
{
"time": {
"1.161.9": "2026-05-11T19:20:39.565Z",
"1.161.12": "2026-05-11T19:26:14.690Z"
},
"versions": []
}TanStack's own summary of the gap is blunt: "OIDC trusted-publisher binding has no per-publish review." Public detection took 20 to 26 minutes, according to the postmortem. On the consumer side, Socket's AI scanner flagged all 84 versions within six minutes of publication. The scanner looks at what changed inside the tarball, and here that was a new 2.3 MB obfuscated router_init.js and an optionalDependencies entry that pulled a git dependency with a prepare hook. The provenance check passed. The contents check did not.
TanStack was not the first case. The Ultralytics compromise on PyPI in December 2024 used the same cache-poisoning route to ship two malicious versions with valid attestations through PyPI trusted publishing, and PyPI's response confirmed no registry flaw was involved. The Bitwarden CLI compromise in April 2026 went through a compromised GitHub Action in the release pipeline, bypassing the project's trusted publishing controls. And current worms have absorbed the technique: the payload in the October 2026 TensorLake compromise targets npm's OIDC token exchange, builds Sigstore provenance, and republishes.
PyPI's documentation states the limit plainly: "Trusted Publishing does not assert the safety of the code or the trustworthiness of its authors." Provenance and trusted publishing are necessary. They are not sufficient, because a compromised pipeline publishes with valid provenance.
Does trusted publishing stop maintainer account takeover?
#Trusted publishing stops one form of maintainer account takeover and leaves two others open. It stops the stolen-token form: when a package allows only trusted publishing, a token harvested from a laptop cannot publish it. That protection is not automatic. A package keeps accepting tokens until a maintainer selects "Require two-factor authentication and disallow tokens" under the package's publishing access settings.
The first gap is the configuration itself. Electron maintainer Samuel Attard warned in maintainer feedback on npm's security plan in September 2025 that anyone with access to a maintainer's npm account could perform a "downgrade attack" by removing trusted publishing. Since a July 31, 2026 change, editing trusted publishing configuration requires an interactive 2FA challenge, so 2FA-bypass tokens can no longer do it. A phished session with 2FA still can.
The second gap is identity binding. In the flaw Bloom Security named SlugFest, npm keyed a trusted publisher to the repository's owner/repo name, not to GitHub's immutable repository_id and repository_owner_id. npm stored the numeric IDs only after a configuration's first successful publish, per WorkOS's analysis. If a configured repository was renamed, transferred, or deleted before then, anyone who claimed the freed name could create a workflow with the matching filename and mint publish tokens. Bloom reported more than 1,500 packages exposed. GitHub's October 2, 2026 fix makes unvalidated configurations expire 48 hours after creation and also rejects OIDC tokens from issue_comment events, alongside the existing pull_request_target block.
PyPI closed the same hole from the start. When a GitHub publisher is configured, PyPI stores the owner's numeric ID and checks the repository_owner_id claim on every exchange, per its internals documentation. GitHub is moving the same way at the source: repositories created after July 15, 2026 get an immutable default OIDC subject of the form repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH.
Publishing credentials compared
#Trusted publishing is one option among several, and npm's controls changed repeatedly between late 2025 and October 2026. The question that matters for each option is what an attacker needs to publish a malicious version.
| Publishing path | What an attacker needs | Human gate per release | Status (October 2026) | Verdict |
|---|---|---|---|---|
| Classic npm token | The token, from any machine | None | Revoked December 9, 2025 | Gone |
| Granular token with 2FA bypass | The token, until it expires | None | Lost account and configuration powers July 31, 2026; direct publish removal planned for January 2027 | Migrate now |
| Stage-only granular token | The token, plus a maintainer's 2FA approval | Yes | Available since September 18, 2026 | Fallback where OIDC is not supported |
| Trusted publishing, direct publish | Code execution inside the configured job | None | Opt-in for configurations created after September 3, 2026 | Better than tokens, still one compromised job away from a release |
| Trusted publishing, stage-only | Code execution inside the job, plus a maintainer's 2FA approval | Yes | Default for new configurations since September 3, 2026 | Use this for anything with real downloads |
| Local publish with 2FA | The maintainer's account and a second factor | The publish is the gate | Supported | Fine for small projects, no provenance |
The verdict: trusted publishing with stage-only allowed actions, plus "disallow tokens" on the package, is the strongest configuration npm offers today. Trusted publishing with direct publish fixes token theft and nothing else. Staged publishing, introduced in npm CLI 11.15.0 in May 2026, is what puts a human back in front of the registry, and since September 3, 2026, npm also blocks approval until its malware scan of the staged version finishes. Staged approval also answers the OpenJS Foundation's December 2025 objection: it advised critical projects not to move to trusted publishing while 2FA could not be enforced on an OIDC publish, and an approval with 2FA is that enforcement. Its cost is real: someone has to approve every release with 2FA, which rules out fully unattended publishing and adds work for maintainers of hundreds of packages. Its limit is real too. An approver who clicks through without reading the diff turns the gate back into a formality.
How should you configure npm trusted publishing?
#Configure npm trusted publishing so a compromised job cannot reach the registry on its own: one job holds the OIDC grant, a protected environment guards it, allowed actions are stage-only, and tokens are disallowed. Each control narrows what a compromised job can do. None makes the job trustworthy.
Give the OIDC grant to one small job. Build and test in jobs without id-token: write. Put npm publish in a separate job that holds the grant and runs no dependency install, no build, and no cache restore. This is the direct fix for the TanStack chain, because the poisoned cache and the OIDC grant met in the same job. It does not cover the artifact you hand to the publish job, which a compromised build job can still tamper with.
Bind the configuration to a GitHub environment with required reviewers. The environment name becomes a claim the registry checks, and the reviewer requirement adds a gate before the job starts. It does not stop code that already runs inside an approved job.
Choose stage-only allowed actions and disallow tokens. This removes the direct path to the registry and the stolen-token path. It costs a 2FA approval per release. Maintainers with large portfolios can roll it out with the npm trust command, which bulk-configures trusted publishing across many packages.
Treat every workflow trigger and cache as input. Avoid pull_request_target and workflow_run in repositories that publish, and use GitHub's cache-mode setting to keep low-trust jobs from writing cache entries. crates.io already refuses trusted publishing from those two triggers. The full workflow side is covered under GitHub Actions security.
On the consuming side, do not gate on provenance alone. A release-age cooldown such as npm's minimumReleaseAge gives scanners time to look at a new version before it reaches you, and npm v12 no longer runs dependency install scripts by default. Both help. Neither replaces looking at what changed in the tarball.
The open problem is that the registry still cannot tell which step inside a job asked for the token. TanStack's postmortem proposes provenance-source verification to flag publishes from unexpected workflow steps, but an OIDC token does not carry a step identity today, and a token read out of Runner.Worker memory looks identical to one the publish step requested. Until CI providers can bind a credential to a single step, trusted publishing answers "which pipeline published this?" and staged publishing answers "did a person agree?". Nothing in the publish path answers "is this code safe to run?". That question has to be answered by reading the package.
