How to Set Up PyPI Trusted Publishing for Secure Releases?
Problem
I woke up to a security alert. Someone had used my PyPI API token to upload a malicious version of my package. The token I created two years ago. The token I forgot to revoke. The token with no expiration date.
My package had 50,000 downloads. All those users potentially got malware.
2026-03-15 03:22:11 UTC - Package upload: mypackage==1.2.32026-03-15 03:22:11 UTC - Uploader: API token (created 2024-01-10)2026-03-15 03:22:11 UTC - WARNING: This was not meThe LiteLLM incident happened exactly this way. Attackers obtained long-lived API tokens and used them to publish malicious packages. My situation was identical.
Why did this happen?
I had been publishing Python packages the “standard” way for years:
- name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: username: __token__ password: ${{ secrets.PYPI_API_TOKEN }}This workflow has a fatal flaw: PYPI_API_TOKEN is a long-lived secret.
PyPI API Token (created Jan 2024) ├── Stored in GitHub Secrets ├── Never expires ├── Visible to anyone with repo access ├── Can be leaked in workflow logs └── Remains valid until manually revokedLong-lived tokens create multiple attack vectors:
- Leaked in logs: Accidentally printed in CI output
- Compromised runner: Malicious GitHub runner steals secrets
- Fork attack: Secrets inherited by malicious forks (if not properly scoped)
- Maintainer compromise: Any maintainer with repo access can extract tokens
A Reddit discussion about the LiteLLM incident made this clear:
“As a maintainer of a few Python packages: make sure to use trusted publishing (no long lived tokens), this alone would have prevented attackers uploading packages in the LiteLLM incident.”
The solution was obvious. I needed to eliminate long-lived tokens entirely.
What is PyPI Trusted Publishing?
Trusted publishing uses OpenID Connect (OIDC) to establish a trust relationship between GitHub Actions and PyPI. No secrets. No tokens. No credentials to leak.
GitHub Actions (workflow runs) | v OIDC Token (short-lived, auto-generated) | v PyPI validates token claims | v PyPI mints temporary API token | v Package published | v Token expires (automatic)The key difference: tokens are generated per-workflow-run. They expire when the job finishes. There’s nothing to leak because the token doesn’t exist until the workflow runs.
How to set up trusted publishing
Step 1: Configure on PyPI
I navigated to my package’s publishing settings:
https://pypi.org/manage/project/YOUR_PACKAGE/settings/publishing/I clicked “Add a new trusted publisher” and filled in:
PyPI Project Name: mypackageOwner: my-github-usernameRepository name: mypackage-repoWorkflow name: publish.ymlEnvironment name: pypiThe workflow name must match my GitHub Actions filename exactly. The environment name is optional but recommended for additional protection rules.
Step 2: Create GitHub Environment
I went to my repository settings and created an environment:
Repository → Settings → Environments → New environmentNamed it pypi (matching the PyPI configuration).
I added protection rules:
- Required reviewers: Requires approval before publishing
- Restrict to branches: Only
mainbranch can publish
Environment: pypi ├── Required reviewers: 1 (requires approval before publish) ├── Restrict to branches: main └── Wait timer: 0 minutes (can be increased for additional safety)Step 3: Update GitHub Actions Workflow
I compared my old workflow with the new one:
jobs: publish: runs-on: ubuntu-latest steps: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: username: __token__ password: ${{ secrets.PYPI_API_TOKEN }} # Long-lived secretjobs: publish: runs-on: ubuntu-latest permissions: id-token: write # CRITICAL: Required for OIDC token generation environment: name: pypi url: https://pypi.org/p/mypackage steps: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 # No username/password - OIDC handles authenticationThe key changes:
- Added
permissions: id-token: write- this tells GitHub to generate OIDC tokens - Added
environment: name: pypi- ties publishing to the protected environment - Removed
usernameandpasswordinputs - OIDC handles auth automatically
Step 4: Revoke Old Tokens
The final step was critical. I went to my PyPI account and revoked all legacy API tokens:
https://pypi.org/manage/account/token/I also removed the secret from GitHub:
Repository → Settings → Secrets and variables → Actions → Delete PYPI_API_TOKENWithout this step, the old token would still work if someone obtained it.
The complete workflow
Here’s my full publish workflow after migration:
name: Publish to PyPI
on: release: types: [published]
permissions: contents: read
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5
- uses: actions/setup-python@v5 with: python-version: "3.x"
- name: Install build dependencies run: python -m pip install --upgrade build
- name: Build distributions run: python -m build
- name: Upload distributions uses: actions/upload-artifact@v4 with: name: dist path: dist/
publish: runs-on: ubuntu-latest needs: build permissions: id-token: write # Required for OIDC environment: name: pypi url: https://pypi.org/p/mypackage steps: - name: Retrieve distributions uses: actions/download-artifact@v5 with: name: dist path: dist/
- name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1This workflow:
- Builds the package in one job
- Passes artifacts to the publish job
- Uses OIDC to authenticate with PyPI
- Publishes to PyPI without any stored secrets
Testing with TestPyPI first
Before publishing to production PyPI, I tested with TestPyPI:
name: Test Publish to TestPyPI
on: push: branches: [main]
jobs: test-publish: runs-on: ubuntu-latest permissions: id-token: write environment: name: testpypi url: https://test.pypi.org/p/mypackage
steps: - uses: actions/checkout@v5
- uses: actions/setup-python@v5 with: python-version: "3.x"
- name: Build run: | pip install build python -m build
- name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/TestPyPI trusted publisher configuration:
https://test.pypi.org/manage/project/YOUR_PACKAGE/settings/publishing/Common mistakes
Mistake 1: Missing id-token: write permission
I made this mistake on my first attempt:
jobs: publish: runs-on: ubuntu-latest # Missing: permissions: id-token: write steps: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1The workflow failed with:
Error: OIDC token issuance is not enabled for this repository.Enable it by adding 'permissions: id-token: write' to your workflow.Fix: Always add permissions: id-token: write to the publish job.
Mistake 2: Leaving old secrets in place
After migrating, I almost forgot to remove the old token:
# Workflow uses OIDC now, but...# secrets.PYPI_API_TOKEN still exists in GitHub# Old token still valid on PyPIThis defeats the purpose. If someone obtains the old token, they can still publish.
Fix: Revoke all PyPI tokens and delete all GitHub secrets immediately after migration.
Mistake 3: Workflow name mismatch
PyPI rejected my publishing attempts:
Error: OpenID Connect token could not be verified.The workflow 'release.yml' is not registered as a trusted publisher.I had configured PyPI for publish.yml but my workflow was named release.yml.
Fix: Ensure the workflow name in PyPI settings exactly matches your YAML filename.
Mistake 4: Not using GitHub environments
Initially I skipped environments:
jobs: publish: runs-on: ubuntu-latest permissions: id-token: write # No environment specifiedThis works but lacks protection rules. Anyone who can push to the repo can publish.
Fix: Use environments with required reviewers for production packages.
Mistake 5: Only migrating some projects
I migrated my active projects but left old projects with legacy tokens.
Old project A - Using PYPI_TOKEN (created 2023, still valid)Old project B - Using PYPI_TOKEN (created 2022, still valid)Active project C - Using trusted publishingAny compromised token exposes its project.
Fix: Audit all projects. Migrate everything. Revoke all legacy tokens.
Why OIDC is more secure
Long-lived Token OIDC Trusted Publishing ---------------- ------------------------Creation Manual Automatic (per run)Expiration Never End of workflowStorage GitHub Secrets NoneVisibility Encrypted but exists Never exists before runRotation Manual AutomaticCompromise window Until revoked Minutes (workflow duration)Audit trail Token ID only Full GitHub Actions contextThe OIDC token includes claims that PyPI validates:
{ "iss": "https://token.actions.githubusercontent.com", "sub": "repo:myuser/mypackage:ref:refs/heads/main", "aud": "pypi", "exp": 1712086400, // Expires in minutes "repository": "myuser/mypackage", "workflow": "publish.yml", "ref": "refs/heads/main", "sha": "abc123...", "actor": "myuser", "event_name": "release"}PyPI verifies all these claims before minting a temporary token. Any mismatch rejects the request.
Manual OIDC token exchange (for debugging)
The pypa/gh-action-pypi-publish action handles OIDC automatically. But understanding the underlying exchange helped me debug issues:
# Step 1: Get OIDC token from GitHub Actionsresp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi")oidc_token=$(jq -r '.value' <<< "${resp}")
# Step 2: Exchange for PyPI API tokenresp=$(curl -X POST https://pypi.org/_/oidc/mint-token \ -d "{\"token\": \"${oidc_token}\"}")api_token=$(jq -r '.token' <<< "${resp}")
# Step 3: Use with twineTWINE_USERNAME=__token__ TWINE_PASSWORD=${api_token} twine upload dist/*This showed me exactly what GitHub sends and what PyPI expects.
Defense in depth
Trusted publishing eliminates one attack vector. But security requires multiple layers:
Layer 1: Authentication ├── Trusted publishing (no long-lived tokens) └── 2FA for maintainers
Layer 2: Authorization ├── GitHub environment protection ├── Required reviewers └── Branch restrictions
Layer 3: Dependency security ├── Pin dependencies ├── Hash requirements └── Regular vulnerability scanning
Layer 4: Monitoring ├── Audit PyPI upload logs ├── Monitor package downloads └── Alert on suspicious activityThe Reddit discussion emphasized:
“Today you need a lockfile pinning all dependencies, not just direct ones and you can’t update to the latest version of anything willy-nilly”
Trusted publishing handles authentication. But I still need dependency pinning, vulnerability scanning, and monitoring.
Migration checklist
[ ] Create GitHub environment "pypi" with protection rules[ ] Add trusted publisher on PyPI (match workflow name exactly)[ ] Update workflow with permissions: id-token: write[ ] Remove username/password from pypa/gh-action-pypi-publish[ ] Add environment reference to publish job[ ] Test with TestPyPI first[ ] Verify successful publish[ ] Revoke all legacy PyPI tokens[ ] Delete all PYPI_* secrets from GitHub[ ] Document migration for other maintainersSummary
PyPI trusted publishing eliminates long-lived API tokens. Instead of storing credentials that never expire, OIDC generates short-lived tokens per workflow run.
The setup requires:
- PyPI configuration: Register your repository as a trusted publisher
- GitHub configuration: Create an environment with protection rules
- Workflow update: Add
id-token: writepermission - Cleanup: Revoke old tokens and delete secrets
The LiteLLM incident would have been prevented by trusted publishing. Long-lived tokens are a liability. Migrate all projects. Revoke all legacy tokens. No exceptions.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 PyPI Trusted Publishers Documentation
- 👨💻 GitHub Actions OIDC for PyPI
- 👨💻 pypa/gh-action-pypi-publish
- 👨💻 PyPI Trusted Publishers Overview
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments