Secure CI/CD for Python Packages: Using PyPI Trusted Publishing with OIDC
Learn how PyPI’s Trusted Publishing feature lets you automate package uploads from CI pipelines using short‑lived OIDC tokens, eliminating the need for long‑term API keys. The blog walks through a GitHub Actions setup, explains the token flow, and highlights trade‑offs.
01 Oct 2026, 13:51 UTC

Why the Current Workflow Isn’t Secure Enough
When you publish a Python package from a CI pipeline you normally store a long‑lived ~/.pypirc API key in a secret vault. If that vault is compromised, an attacker can push arbitrary versions to any project you own. The problem is that the key is static and has no built‑in revocation or project‑scoping.
PyPI’s Trusted Publishing feature solves this by replacing the static key with a short‑lived, project‑scoped token that is issued on demand via an OpenID Connect (OIDC) identity provider. The token lives only a few minutes, is bound to a single package and version, and can be refreshed automatically by the CI system. This makes it possible to run fully automated publish steps without ever exposing a persistent credential.
How Trusted Publishing Works
Trusted Publishing is a two‑step exchange:
- CI emits an OIDC JWT – The CI runner (e.g., GitHub Actions) requests a short‑lived JSON Web Token (JWT) from its OIDC provider. The token contains an
audclaim that must matchhttps://upload.pypi.org/legacy/and asubclaim identifying the repository or project. - PyPI issues an upload token – The runner calls
https://upload.pypi.org/tokenwith the JWT as a bearer token. PyPI validates the JWT, checks the audience, and returns a scoped upload token that can be used withtwinefor a single upload.
Because the upload token is only valid for the specific project and version, a compromised CI runner cannot affect other packages. Additionally, the token expires quickly (typically 5–10 minutes), limiting the window for misuse.
Setting Up Trusted Publishing in GitHub Actions
The most common OIDC provider is GitHub itself. The following workflow demonstrates the minimal steps to publish a source distribution and a wheel to test.pypi.org using Trusted Publishing.
name: Publish to TestPyPI
on:
push:
tags:
- "v*"
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build tools
run: pip install --upgrade setuptools wheel
- name: Build distributions
run: python -m build
- name: Get OIDC token
id: oidc
uses: actions/github-script@v6
with:
script: |
const token = await github.actions.getSignedOidcToken({
audience: 'https://upload.pypi.org/legacy/'
});
return token;
- name: Upload to TestPyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ steps.oidc.outputs.token }}
run: |
pip install twine
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
Key points:
- The job must request
id-token: writepermissions; otherwise GitHub will refuse to issue a signed token. - Both
__token__and the OIDC token are passed as environment variables totwine. - The upload URL must point to the same audience that the JWT claims.
Verifying the Upload
After the upload completes, you can confirm that the uploader is shown as __token__ on the package page. The file’s metadata will also contain the aud claim matching https://upload.pypi.org/legacy/, proving the token exchange succeeded.
Trade‑offs and Limitations
- Supported IdPs Only – PyPI currently trusts a limited set of OIDC providers (GitHub, GitLab, Google, Azure AD). Custom internal IdPs are not yet supported; you would need to add the provider’s public keys to the repository’s trusted list.
- Short‑Lived Tokens – The upload token expires after 5–10 minutes. Long‑running builds must refresh the token or re‑run the job; otherwise
twinewill fail with a 401. - Self‑Hosted Runners – If you run the job on a self‑hosted runner that does not automatically trust the OIDC issuer, you must manually configure the runner to accept the issuer’s keys; otherwise the token request to
/tokenwill be rejected. - Audit Trail – Since the uploader appears as
__token__, you lose a human‑readable identifier in the upload history. You can mitigate this by checking the audit log on PyPI or by adding a custom tag in the package metadata.
When to Use Trusted Publishing
If you:
- Run CI pipelines in a public cloud (GitHub Actions, GitLab CI, Azure Pipelines) and want to avoid storing any API keys.
- Need to enforce per‑project, per‑version isolation for uploads.
- Prefer a token that automatically expires and can’t be reused after a breach.
Trusted Publishing is ideal. For internal projects that require custom IdPs or long‑running build processes, you may still need a fallback strategy.
Actionable Checklist
- Enable Trusted Publishing on PyPI for the target project.
- Configure your CI job to request an OIDC token with audience
https://upload.pypi.org/legacy/. - Set
TWINE_USERNAME=__token__andTWINE_PASSWORDto the OIDC token. - Run
twine uploadagainst the correct repository URL. - Verify the upload on
test.pypi.orgorpypi.organd check the audit log. - If you encounter a 401, confirm the token is fresh and that the audience matches.
By following these steps you can publish packages securely without ever exposing a permanent API key.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.