How OIDC trusted publishing works¶
Summary
OIDC lets GitHub Actions publish to PyPI without passwords or stored tokens. The workflow proves its identity with a signed token from GitHub, and PyPI answers with an API token that is valid for 15 minutes.
Stored API tokens versus OIDC¶
Stored API token
- Long-lived: valid until someone revokes it
- Kept in GitHub secrets, from where it can leak
- Rotated by hand
Trusted publishing (OIDC)
- No secret stored anywhere
- The API token PyPI issues expires after 15 minutes
- Bound to one repository, workflow file, and environment
In the words of the PyPI docs, "PyPI's normal API tokens are long-lived." "Trusted Publishing avoids this problem because the tokens minted expire automatically."
The flow¶
OIDC (OpenID Connect) is an identity layer on OAuth 2.0. GitHub issues a signed token (a JWT) that says which repository, workflow, and environment a job runs in; PyPI checks it against the trusted publishers registered for a project.
┌──────────────────────┐
│ Maintainer │
│ gh workflow run │
│ publish_pypi.yml │
│ --ref v1.0.0 │
└──────┬───────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ GitHub Actions workflow │
│ │
│ 1. Builds the packages │
│ 2. Requests a JWT from GitHub's OIDC provider │
│ 3. Sends the JWT to PyPI │
└──────┬──────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PyPI │
│ │
│ ✓ Verifies the JWT signature (GitHub's public key) │
│ ✓ Matches the claims against the trusted publishers: │
│ • repository: TUW-GEO/canvodpy │
│ • workflow file: publish_pypi.yml │
│ • environment: pypi │
│ → Issues an API token (15 minutes) for every project │
│ with a matching publisher │
└──────┬──────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ The workflow uploads the packages with that API token │
└─────────────────────────────────────────────────────────┘
Step by step¶
1. Registration (one-time setup)¶
You register a trusted publisher for each project on PyPI. It says,
in effect, that GitHub Actions in repository TUW-GEO/canvodpy, workflow
publish_pypi.yml, environment pypi, may publish canvod-readers.
No secrets are exchanged. This only tells PyPI what to expect.
2. Workflow run¶
A maintainer starts the workflow on a release tag
(gh workflow run publish_pypi.yml --ref v1.0.0). Its publish job has:
permissions:
id-token: write # ← may request an OIDC token
3. The token GitHub issues¶
GitHub's OIDC provider issues a JWT with claims like these (shortened):
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:TUW-GEO/canvodpy:environment:pypi",
"aud": "pypi",
"repository": "TUW-GEO/canvodpy",
"repository_owner": "TUW-GEO",
"repository_owner_id": "<numeric owner id>",
"workflow": "Publish to PyPI",
"job_workflow_ref": "TUW-GEO/canvodpy/.github/workflows/publish_pypi.yml@refs/tags/v1.0.0",
"environment": "pypi",
"ref": "refs/tags/v1.0.0"
}
GitHub signs it with its private key. Anyone can verify the signature with
GitHub's public keys at
https://token.actions.githubusercontent.com/.well-known/jwks.
4. Exchange and upload¶
uv publish --trusted-publishing always:
- Gets the JWT from GitHub's OIDC provider
- Sends it to PyPI, which verifies it and returns a short-lived API token
- Uploads the packages with that token
5. What PyPI checks¶
From PyPI's internals page:
- The signature, "to ensure that it's actually coming from the expected provider"
- The claims against the registered publishers: repository, workflow file, and environment
- The
repository_owner_idclaim, which PyPI always checks for GitHub publishers
If the claims match, PyPI issues "a short-lived (15 minute) PyPI API token," which "is scoped to every project with a matching Trusted Publisher." If nothing matches, the upload is refused.
Scope of a publisher¶
Each trusted publisher is bound to:
| Scope | Example | Purpose |
|---|---|---|
| Repository | TUW-GEO/canvodpy |
Only this repository can publish |
| Workflow | publish_pypi.yml |
Only this workflow file |
| Environment | pypi |
Only this environment |
| Package | canvod-readers |
Only this PyPI project |
So a JWT from another repository, workflow file, or environment gets no token, and the token a run gets works only for the projects whose publishers match.
Why several publishers?¶
The publisher belongs to the project, so every package needs its own:
PyPI trusted publishers:
┌──────────────────┬──────────────────┬──────────────────┬──────┐
│ Package │ Repository │ Workflow │ Env │
├──────────────────┼──────────────────┼──────────────────┼──────┤
│ canvod-readers │ TUW-GEO/canvodpy │ publish_pypi.yml │ pypi │
│ canvod-auxiliary │ TUW-GEO/canvodpy │ publish_pypi.yml │ pypi │
│ canvod-grids │ TUW-GEO/canvodpy │ publish_pypi.yml │ pypi │
│ ... │ ... │ ... │ ... │
└──────────────────┴──────────────────┴──────────────────┴──────┘
If you only register canvodpy, the workflow can't publish canvod-readers.
Deployment environment¶
environment: pypi # ← Why this matters
- Protection rules: the environment can require a reviewer's approval before the job runs, and limit which branches or tags may deploy to it.
- History: the repository lists every deployment to the environment and who approved it.
- OIDC scope: the JWT names the environment, so a publisher registered
for environment
pypimatches no run in any other environment.
Common configuration mistakes¶
1. Mismatched environment name¶
Workflow says:
environment: pypi
PyPI registered with:
Environment: py-pi # ← Note the dash!
Result: no publisher matches, and the upload is refused.
2. Mismatched workflow file¶
Workflow filename:
.github/workflows/publish-pypi.yml # ← Note the dash!
PyPI registered with:
Workflow: publish_pypi.yml # ← Note the underscore!
Result: no publisher matches, and the upload is refused.
3. Package not registered¶
You set up a publisher for canvodpy but forgot canvod-readers.
Result: canvodpy publishes, canvod-readers fails.
Further reading¶
Questions? Open an issue or a discussion.