Safeguard
Concepts

What is a Trusted Publisher (PyPI and npm)

A trusted publisher lets your CI workflow publish packages with short-lived OIDC tokens instead of stored API keys. Here's how it works on PyPI and where npm stands.

Aisha Bello
Application Security Engineer
7 min read

A trusted publisher is a package registry configuration that authorizes a specific CI workflow — rather than a person holding a token — to publish a package, using short-lived OIDC tokens minted at build time instead of long-lived API keys stored as secrets. PyPI shipped it in April 2023, RubyGems followed in late 2023, and crates.io and npm have both committed to the model. The idea is simple: the registry already trusts your CI provider to say "this token was issued to workflow release.yml in repo acme/widget on the main branch," so let that signed statement be the credential. No secret to store, rotate, leak, or phish.

The problem it kills: the eternal token

The traditional publishing flow is a PYPI_API_TOKEN or NPM_TOKEN pasted into CI secrets in 2021 and never touched again. That token is a bearer credential: anyone who obtains it can publish as you, from anywhere, indefinitely. And CI is exactly where tokens get obtained — the Codecov compromise harvested environment variables from thousands of pipelines, and npm's history of account takeovers (ua-parser-js in 2021, node-ipc's maintainer going rogue aside) shows what happens when publish rights are a static string.

Long-lived tokens fail in boring ways too. They outlive employees. They get scoped too broadly because scoping is tedious. They end up in shell history, in ~/.npmrc on four laptops, and in build logs when someone echoes the environment while debugging. GitGuardian's annual scans keep finding millions of live credentials in public commits, and registry tokens are reliably among them.

A trusted publisher replaces all of that with a token that lives for minutes, is bound to one workflow in one repo, and never exists anywhere a human could copy it.

How the OIDC exchange actually works

The flow has three parties: your CI provider (the identity provider), the registry (the relying party), and your workflow.

  1. Your workflow requests an OIDC token from the CI provider. On GitHub Actions this requires the id-token: write permission and yields a JWT signed by GitHub containing claims like repository: acme/widget, workflow_ref, ref: refs/heads/main, and environment.
  2. The publish tool sends that JWT to the registry.
  3. The registry verifies the signature against GitHub's public keys, checks the claims against the trusted publisher configuration you registered ("releases of widget may come from acme/widget, workflow release.yml, environment pypi"), and if everything matches, mints a short-lived, single-purpose upload token.

The credential is the identity of the workflow itself. There is nothing to rotate because nothing persists.

Setting it up on PyPI

On PyPI, go to your project's settings, "Publishing," and add a trusted publisher with four fields: owner (acme), repository (widget), workflow filename (release.yml), and — strongly recommended — a GitHub Actions environment name (pypi). Then the workflow is nearly trivial:

name: release
on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - run: python -m pip install build && python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1

No password: input, no secret. The pypa/gh-action-pypi-publish action detects OIDC and does the exchange. PyPI also supports "pending publishers" so a brand-new project's first release can go through trusted publishing — register the publisher before the project exists, which closes the bootstrap gap where a name-squatter could grab the name first.

Two details worth the extra five minutes: bind the publisher to a GitHub environment with required reviewers, so publishing needs an approval click even if someone compromises a workflow on a branch; and keep the release workflow file minimal, because every step in it runs with the ability to publish.

Where npm stands (and what to do meanwhile)

As of this writing, npm does not yet offer general trusted publishing — it's on the public roadmap, and the ecosystem pressure is real. What npm does have today is the adjacent half of the story: provenance attestations. Running npm publish --provenance from GitHub Actions or GitLab CI uses the same OIDC machinery to generate a signed, Sigstore-backed statement of which repo, commit, and workflow built the package, displayed on the npm package page.

Until trusted publishing lands on npm, the pragmatic hardening stack is:

ControlWhat it does
Granular access token, publish-only, scoped to specific packagesLimits blast radius of a leak
Token expiry set to 30–90 daysForces rotation
npm publish --provenanceVerifiable build origin for consumers
2FA required for publish on the orgBlocks pure credential-stuffing takeovers
CIDR-restricted tokensPublish only from CI egress IPs

The deeper pattern — swap every stored CI credential for federated identity — extends well beyond registries; we've written up the general case in OIDC vs static credentials in CI.

Pitfalls and honest limits

Trusted publishing moves trust; it doesn't abolish it. Things that still bite:

  • Sloppy claim bindings. Registering owner: acme with a reusable workflow and no environment constraint means any job that can invoke that workflow can publish. Bind to the exact workflow file and an environment.
  • The workflow is now the crown jewel. Anyone who can merge changes to release.yml — or to an action it uses: — can publish. Pin third-party actions by commit SHA, require review on the workflow path, and use CODEOWNERS.
  • Repo-jacking style resurrection. If acme/widget is deleted and the acme org name lapses, someone re-registering the name could satisfy an old publisher binding. PyPI mitigates with additional environment and user-id checks, but clean up publisher configs for dead repos.
  • It's authentication, not review. A trusted publisher happily publishes malicious code that a compromised workflow builds. You still want dependency and artifact scanning — this is where SCA and consuming provenance in a platform like Safeguard complement the registry-side control rather than duplicate it.

None of these are arguments against adoption. They're the residual risk list you accept in exchange for deleting every publish token you own — a trade you should take on every package you maintain.

Frequently asked questions

Is a trusted publisher the same as npm package provenance?

No, they're complementary halves. Trusted publishing is authentication — how the workflow proves it may publish, replacing API tokens. Provenance is attestation — a signed public record of where the artifact was built. PyPI trusted publishing and npm publish --provenance both ride on the same CI OIDC tokens, which is why they're often confused.

Which CI providers can be trusted publishers on PyPI?

PyPI currently supports GitHub Actions, GitLab CI/CD, Google Cloud Build, and ActiveState. GitHub Actions is by far the most common and the best documented; the claims model differs slightly per provider but the exchange is the same.

Can I use a trusted publisher from a self-hosted runner?

Yes for GitHub Actions — the OIDC token is issued by GitHub regardless of where the runner executes, and the claims describe the workflow, not the machine. Just remember the runner now effectively holds publish rights while the job runs, so isolate and harden it accordingly.

Should I delete my old API tokens after enabling trusted publishing?

Yes, and this is the step teams skip. A trusted publisher added alongside a forgotten long-lived token leaves your attack surface unchanged. Revoke the tokens, then confirm the only remaining publish path is the OIDC one; PyPI's project security settings list both.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.