OIDC Trusted Publishing: How It Works¶
TL;DR
OIDC lets GitHub Actions publish to PyPI without passwords or long-lived tokens. It uses cryptographic JWT proofs that expire in 10 minutes — no stored secrets.
Traditional vs OIDC¶
API Tokens (old way)
- Long-lived tokens (months/years)
- If GitHub is breached, token leaks
- Broad permissions (all packages)
- Need manual rotation
- Can be accidentally committed to git
OIDC (2026 standard)
- Short-lived JWT proof (10 minutes)
- No stored secrets anywhere
- Scoped to a specific repo + workflow
- Zero rotation overhead
- Cryptographically verified identity
The Traditional Way (API Tokens)¶
You → Generate API token on PyPI → Store in GitHub Secrets → Workflow uses token
Problems: - Long-lived tokens (months/years) - If GitHub is compromised, token leaks - Token has broad permissions (all packages) - Need manual rotation - Can be accidentally committed to git
The OIDC Way (2026 Standard)¶
Overview¶
OIDC (OpenID Connect) is an authentication protocol built on OAuth 2.0. Instead of storing long-lived tokens, it uses short-lived cryptographic proofs (JWT tokens).
The Flow¶
┌─────────────┐
│ Developer │
│ git push │
│ --tags │
└──────┬──────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ GitHub Actions Workflow │
│ │
│ 1. Builds packages │
│ 2. Requests JWT from GitHub OIDC provider │
│ 3. GitHub generates signed JWT with workflow identity │
│ 4. Sends JWT + packages to PyPI │
└──────┬──────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PyPI Validation │
│ │
│ For each package: │
│ ✓ Verify JWT signature (GitHub's public key) │
│ ✓ Check JWT not expired (~10 min TTL) │
│ ✓ Validate claims: │
│ • repository: nfb2021/canvodpy ✓ │
│ • workflow: publish_testpypi.yml ✓ │
│ • environment: testpypi ✓ │
│ • package name: canvod-readers ✓ │
│ │
│ All match? → PUBLISH │
│ Mismatch? → REJECT │
└─────────────────────────────────────────────────────────┘
Step-by-Step Breakdown¶
1. Registration Phase (One-Time Setup)¶
You register a trust relationship on PyPI:
"I trust GitHub Actions from repository
nfb2021/canvodpy, specifically the workflowpublish_testpypi.ymlrunning in environmenttestpypi, to publish packagecanvod-readers"
No secrets exchanged! This just tells PyPI what to expect.
2. Workflow Execution¶
When you push a tag (v0.1.0-beta.1), the workflow runs:
permissions:
id-token: write # ← Request OIDC JWT capability
3. JWT Token Generation¶
GitHub's OIDC provider generates a JWT containing:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:nfb2021/canvodpy:environment:testpypi",
"aud": "https://test.pypi.org",
"repository": "nfb2021/canvodpy",
"repository_owner": "nfb2021",
"repository_owner_id": "93122788",
"workflow": "publish_testpypi.yml",
"workflow_ref": "nfb2021/canvodpy/.github/workflows/publish_testpypi.yml@refs/tags/v0.1.0-beta.1",
"environment": "testpypi",
"ref": "refs/tags/v0.1.0-beta.1",
"iat": 1738677600, // Issued at
"exp": 1738678200 // Expires at (10 minutes later)
}
Cryptographically signed with GitHub's private key.
4. Publish Action¶
The pypa/gh-action-pypi-publish action:
1. Retrieves the JWT from GitHub's OIDC provider
2. Bundles it with the package files
3. Sends to PyPI/TestPyPI upload endpoint
5. PyPI Validation¶
PyPI receives the request and validates:
1. Signature Verification
✓ JWT signed by GitHub? (verify with GitHub's public key)
2. Expiration Check
✓ Current time < exp claim?
✓ iat claim reasonable?
3. Claims Validation
✓ repository == "nfb2021/canvodpy"?
✓ workflow == "publish_testpypi.yml"?
✓ environment == "testpypi"?
✓ Package name == "canvod-readers"? (matches upload)
4. Trust Relationship Lookup
✓ Is there a registered publisher matching these claims?
✓ Does the publisher have permission for this package?
All pass? → Package published.
Any fail? → 403 Forbidden with detailed error
Security Properties¶
No Long-Lived Secrets¶
Traditional tokens: - Valid for months/years - If leaked, usable until manually revoked - Broad permissions
OIDC JWTs: - Valid for ~10 minutes - Automatically expire - Scoped to specific workflow + environment + package
Cryptographic Verification¶
How JWT signatures work:
- GitHub has a private key (secret, never shared)
- GitHub publishes a public key at
https://token.actions.githubusercontent.com/.well-known/jwks - JWT is signed with private key
- PyPI verifies signature with public key
Why it's secure: - Only GitHub can create valid JWTs (has private key) - Anyone can verify JWTs (public key is public) - Forging a JWT requires private key (mathematically infeasible)
Scoped Permissions¶
Each trust relationship is scoped to:
| Scope | Example | Purpose |
|---|---|---|
| Repository | nfb2021/canvodpy |
Only this repo can publish |
| Workflow | publish_testpypi.yml |
Only this workflow file |
| Environment | testpypi |
Only this environment |
| Package | canvod-readers |
Only this PyPI package |
Example attack prevention:
- Can't use JWT from different repo
- Can't use JWT from different workflow
- Can't use JWT from different environment
- Can't publish to different package
- Can't replay expired JWT
- Can't modify JWT claims (signature breaks)
Audit Trail¶
GitHub records: - Who triggered the workflow - What tag/branch/commit - When it ran - What environment was used
PyPI records: - Which JWT claims were used - When publish occurred - Which files were uploaded
Full traceability: "v0.1.0 was published by workflow X triggered by user Y at time Z"
Why Multiple Publishers?¶
Each package needs its own trust relationship:
PyPI Trust Registry:
┌──────────────────┬─────────────────┬──────────────────────┬─────────┐
│ Package │ Repository │ Workflow │ Env │
├──────────────────┼─────────────────┼──────────────────────┼─────────┤
│ canvod-readers │ nfb2021/canvodpy│ publish_testpypi.yml │ testpypi│
│ canvod-auxiliary │ nfb2021/canvodpy│ publish_testpypi.yml │ testpypi│
│ canvod-grids │ nfb2021/canvodpy│ publish_testpypi.yml │ testpypi│
│ ... │ ... │ ... │ ... │
└──────────────────┴─────────────────┴──────────────────────┴─────────┘
Why? PyPI validates per-package:
"Does this JWT have permission to publish THIS specific package?"
If you only register canvodpy, the workflow can't publish canvod-readers.
GitHub Environments¶
environment: testpypi # ← Why this matters
Security Benefits¶
1. Protection Rules - Require manual approval before publish - Limit to specific branches - Add reviewers
2. Secrets Scoping - Environment-specific secrets (if needed) - Separate test vs production
3. Audit & History - See all deployments to this environment - Track who approved what - Deployment logs
4. OIDC Scoping
- JWT includes environment name in claims
- Different environments = different publishers
- Example: testpypi environment → test.pypi.org
pypi environment → pypi.org
Common Misconfigurations¶
1. Mismatched Environment Name¶
Workflow says:
environment: testpypi
PyPI registered with:
Environment: test-pypi # ← Note the dash!
Result: JWT validation fails (environment claim doesn't match)
2. Mismatched Workflow Filename¶
Workflow filename:
.github/workflows/publish-testpypi.yml # ← Note the dash!
PyPI registered with:
Workflow: publish_testpypi.yml # ← Note the underscore!
Result: JWT validation fails
3. Wrong Repository Owner¶
Your GitHub username: nfb2021
PyPI registered with: NFB2021 (different case!)
Result: JWT validation fails (case-sensitive!)
4. Forgot to Register Package¶
You set up OIDC for canvodpy but forgot canvod-readers.
Result: canvodpy publishes successfully, canvod-readers fails
Advantages Over Other Methods¶
vs. Username/Password¶
| Method | OIDC | Username/Password |
|---|---|---|
| Security | Cryptographic | Password-based |
| Lifetime | 10 minutes | Forever (until changed) |
| Revocation | Automatic expiry | Manual change |
| MFA Compatible | Always | Sometimes |
| Audit Trail | Built-in | Manual logging |
vs. API Tokens¶
| Method | OIDC | API Token |
|---|---|---|
| Storage | None needed | GitHub Secrets |
| Rotation | Not needed | Manual (quarterly?) |
| Scope | Per-package | Account-wide |
| Leak Risk | Low (expires fast) | High (long-lived) |
| Setup | One-time registration | Generate + store |
Real-World Analogy¶
API Tokens = House Key - You give someone a key - They can use it anytime - If lost, anyone can use it - Must change locks to revoke
OIDC = Hotel Key Card - Hotel validates your identity (JWT) - Issues temporary card (expires checkout time) - Card only works for your room (scoped) - Card automatically deactivates (expiration) - Can't duplicate card (cryptographic signature) - Full audit trail (who, when, what room)
Further Reading¶
- PyPI Trusted Publishers Documentation
- OIDC Specification
- GitHub OIDC Documentation
- JWT.io - Debug JWTs
- GitHub JWKS Endpoint
Questions? Open an issue or discussion!