Publishing Packages Without Long-Lived Tokens: Trusted Publishing with OIDC on npm, PyPI and crates.io
A publish token sitting in a CI secret leaks, expires and is broader than it needs to be. Trusted publishing swaps it for the CI system's OIDC identity and a token that lives for minutes. How npm, PyPI and crates.io differ, a workflow example, the migration order, and the traps around file names, environments, fork pull requests, republishing and the dual-path transition.
The usual way to publish to npm, PyPI or crates.io is to create an API token on the registry and store it as a CI secret. Setup takes five minutes, and then nobody touches it for years. Yet that token can ship code to every machine that installs your package. All three registries now offer the same alternative: trusted publishing. On every run the CI system proves its own identity, and the registry hands back a token that lives for minutes. There is no secret left to store.
Three problems with long-lived tokens
Leaks. A publish token is a bearer secret: it works for whoever holds it, from wherever they are. A variable echoed into a CI log or an .npmrc on someone's laptop is enough, and the token stays valid until revoked.
Expiry. A token that never expires is a liability; one that does expire breaks your release on the worst possible day. npm settled the question: at the end of 2025 classic tokens were permanently revoked, and granular tokens with write access are now capped at 90 days.
Scope. On PyPI an account-wide token can upload to every project on the account. More importantly, no token knows anything about the code using it: anyone with write access to the repository can run a workflow that reads the secret from any branch.
How trusted publishing works
The mechanism is built on OpenID Connect (OIDC):
- You register a publisher in the package settings: repository owner, repository name, workflow file name and, optionally, an environment name. No secret is created.
- The release job asks the CI system's OIDC provider for a signed identity token (a JWT). On GitHub Actions that takes the
id-token: writepermission. The token carries the repository, owner, workflow, branch or tag, and environment. - The registry verifies the signature against the provider's public keys, compares the claims with the definition and, if they match, mints a short-lived publish token: 15 minutes on PyPI, 30 on crates.io.
- The package is uploaded with that token. The official crates.io action also revokes it when the job finishes.
Identity stops being "anyone who knows this string" and becomes "this workflow, in this repository, in this environment". Trust does not vanish, it moves: anyone who can change the workflow file, or the code running inside it, can now publish.
npm, PyPI and crates.io side by side
npm. Supports GitHub Actions, GitLab CI/CD and CircleCI, but only on cloud-hosted runners. It needs npm CLI 11.5.1 and Node 22.14.0 or later, and npm publish performs the exchange itself. A public package published from a public repository automatically gets provenance tying the version to its workflow run. Three details are easy to miss: repository.url in package.json must match the repository exactly; direct npm publish has to be enabled in the definition (npm stage publish, which waits for manual approval, is always allowed); and a new definition that does not publish successfully within two days expires.
PyPI. The broadest support: GitHub Actions, GitLab CI/CD, Google Cloud and ActiveState. On GitHub you use pypa/gh-action-pypi-publish, which also produces signed attestations for the distribution files by default. A project that does not exist yet can get a pending publisher, but that does not reserve the name; if someone else registers it first, the definition is invalidated.
crates.io. Supports GitHub Actions, plus GitLab CI/CD on GitLab.com as a public beta. A crate's first version must be published with an API token, since trusted publishing can only be set up for existing crates. rust-lang/crates-io-auth-action does the exchange and its token goes to cargo publish as CARGO_REGISTRY_TOKEN. A crate setting can turn token publishing off entirely.
A workflow example
This follows the official Python packaging guide: build and publish run as separate jobs, and only the publish job can request an identity token.
name: release
on:
push:
tags:
- "v*"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: "3.x"
- run: python3 -m pip install build --user
- run: python3 -m build
- uses: actions/upload-artifact@v5
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v6
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
The split is not a matter of taste: the guide does not support building inside the publishing job, which keeps build scripts and dependencies away from the identity token. On npm the job ends with actions/setup-node and npm publish, with no NODE_AUTH_TOKEN anywhere.
Migration order
Define first, publish second, and delete the old token only after the first green release.
- Define. Because of npm's two day expiry, create the definition right before the release. Treat the token used for a new crate's first version as single use.
- Publish. Add
id-token: writeand the environment, and take the token out of the workflow without revoking it on the registry yet. Cut a new version. - Verify, then delete. Confirm the release went through OIDC, revoke the token on the registry and close the path: "Require two-factor authentication and disallow tokens" in the npm package settings, the option that requires trusted publishing on crates.io. On PyPI, delete the token in your account settings.
My preference is to start new packages on trusted publishing from day one and to tie the switch for existing ones to their next routine release. The reasoning: the only real risk of the migration is a broken release. A routine release that breaks keeps nobody waiting, while debugging authentication in the middle of an urgent security patch is the worst moment you could pick.
Pitfalls
The workflow file name. The registry knows the workflow by its file name, not by its name: field. A cleanup that renames release.yml to publish.yml silently breaks publishing. npm wants the bare file name with its .yml extension, compares case-sensitively and fails with ENEEDAUTH; PyPI returns invalid-publisher. PyPI does not support reusable workflows (workflow_call), and npm checks the calling workflow rather than the one that publishes.
The environment. If the definition names an environment, the job must run in one with exactly that name. Skipping it is tempting and wrong: none of the three registries has a branch or tag field, so the GitHub environment's deployment rules are where you allow only tags starting with v and add required reviewers. Without one, anyone with write access can publish by running a workflow with the same file name from any branch. Protect release tags with repository rulesets as well.
Pull requests from forks. Workflows running for fork pull requests cannot obtain the upstream repository's OIDC token. The danger is pull_request_target, which runs in the base repository's context with its permissions: if it runs the pull request's code with id-token: write, an outside contribution can publish your package. That is why crates.io blocks pull_request_target and workflow_run from trusted publishing entirely. Trigger the release workflow only on tags or by hand.
Republishing. A version number can be spent only once on all three: npm never accepts a package@version again, even after an unpublish; PyPI rejects any file name used before; crates.io never lets a version be overwritten. Rerunning a job that died halfway produces "already exists" errors. Even the PyPI action's documentation advises against skip-existing for the real PyPI; I would rather bump the version and cut a clean release. Also put the exchange right before the upload, or tests running after it can eat the short lifetime.
The dual-path transition. During the migration both the token and OIDC work, and a green release does not say which was used. If the npm CLI cannot detect an OIDC environment it quietly falls back to a traditional token, so with NODE_AUTH_TOKEN still set, a broken definition goes unnoticed until the day you delete the token. That is why step two removes it from the workflow. To confirm, check "Uploaded using Trusted Publishing?" in the PyPI file details, and, for public repositories, the version's provenance on npm. Deleting a CI secret revokes nothing; copies elsewhere keep working until the token is revoked on the registry.
What it does not solve
Trusted publishing settles who the publisher is, not whether the code deserves trust: provenance proves which repository a package came from, not that its code is harmless. With trust now living in the repository, required reviews, tag rules and actions pinned to full commit hashes are part of publishing security. If you publish from CI you host yourself, you are stuck with tokens; scope them to one project and keep them short-lived.
The easiest publish token to rotate is the one you never created.