Skip to content

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.

PyPI upload logs showed unauthorized access
2026-03-15 03:22:11 UTC - Package upload: mypackage==1.2.3
2026-03-15 03:22:11 UTC - Uploader: API token (created 2024-01-10)
2026-03-15 03:22:11 UTC - WARNING: This was not me

The 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:

My old publish workflow (INSECURE)
- 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.

Token lifecycle problem
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 revoked

Long-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.

How OIDC trusted publishing works
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:

PyPI Trusted Publisher configuration path
https://pypi.org/manage/project/YOUR_PACKAGE/settings/publishing/

I clicked “Add a new trusted publisher” and filled in:

Trusted publisher fields
PyPI Project Name: mypackage
Owner: my-github-username
Repository name: mypackage-repo
Workflow name: publish.yml
Environment name: pypi

The 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:

GitHub Environment setup path
Repository → Settings → Environments → New environment

Named it pypi (matching the PyPI configuration).

I added protection rules:

  • Required reviewers: Requires approval before publishing
  • Restrict to branches: Only main branch can publish
Environment protection rules
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:

BEFORE: Legacy token-based (INSECURE)
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 secret
AFTER: Trusted publishing with OIDC (SECURE)
jobs:
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 authentication

The key changes:

  1. Added permissions: id-token: write - this tells GitHub to generate OIDC tokens
  2. Added environment: name: pypi - ties publishing to the protected environment
  3. Removed username and password inputs - 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:

Token revocation path
https://pypi.org/manage/account/token/

I also removed the secret from GitHub:

GitHub Secret removal path
Repository → Settings → Secrets and variables → Actions
→ Delete PYPI_API_TOKEN

Without this step, the old token would still work if someone obtained it.

The complete workflow

Here’s my full publish workflow after migration:

.github/workflows/publish.yml
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/v1

This workflow:

  1. Builds the package in one job
  2. Passes artifacts to the publish job
  3. Uses OIDC to authenticate with PyPI
  4. Publishes to PyPI without any stored secrets

Testing with TestPyPI first

Before publishing to production PyPI, I tested with TestPyPI:

.github/workflows/test-publish.yml
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:

TestPyPI publisher path
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:

MISTAKE: Missing OIDC permission
jobs:
publish:
runs-on: ubuntu-latest
# Missing: permissions: id-token: write
steps:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

The workflow failed with:

Error message
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:

MISTAKE: Old secret still present
# Workflow uses OIDC now, but...
# secrets.PYPI_API_TOKEN still exists in GitHub
# Old token still valid on PyPI

This 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: Publisher not found
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:

Without environment (less secure)
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
# No environment specified

This 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.

Risk: Legacy tokens remain valid
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 publishing

Any compromised token exposes its project.

Fix: Audit all projects. Migrate everything. Revoke all legacy tokens.

Why OIDC is more secure

Security comparison: Token vs OIDC
Long-lived Token OIDC Trusted Publishing
---------------- ------------------------
Creation Manual Automatic (per run)
Expiration Never End of workflow
Storage GitHub Secrets None
Visibility Encrypted but exists Never exists before run
Rotation Manual Automatic
Compromise window Until revoked Minutes (workflow duration)
Audit trail Token ID only Full GitHub Actions context

The OIDC token includes claims that PyPI validates:

OIDC token claims
{
"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:

Manual OIDC exchange for debugging
# Step 1: Get OIDC token from GitHub Actions
resp=$(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 token
resp=$(curl -X POST https://pypi.org/_/oidc/mint-token \
-d "{\"token\": \"${oidc_token}\"}")
api_token=$(jq -r '.token' <<< "${resp}")
# Step 3: Use with twine
TWINE_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:

Supply chain security 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 activity

The 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

Complete 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 maintainers

Summary

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:

  1. PyPI configuration: Register your repository as a trusted publisher
  2. GitHub configuration: Create an environment with protection rules
  3. Workflow update: Add id-token: write permission
  4. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments