Secure PyPI uploads with scoped API tokens in CI pipelines
Learn how to create and use PyPI scoped API tokens to limit upload permissions in GitHub Actions, with a step‑by‑step example and verification steps.
05 Apr 2026, 08:20 UTC

Problem: over‑privileged credentials
When automating package publishing, many teams store a plain password or a generic API token that grants full account access. If that secret is leaked, an attacker can upload new versions to any of your projects or even delete existing files. The risk grows with the number of repositories and CI jobs that share the same credential.
Creating a scoped token
- Log in to pypi.org (or test.pypi.org for experimentation).
- Go to Account → API tokens.
- Click Add API token.
- Give the token a descriptive name, e.g. "github-actions-upload".
- Under Projects, select the specific package(s) this token should be allowed to upload to.
- Enable only the Upload scope; leave all other scopes disabled.
- Click Create token and copy the generated value – it will look like
pypi-AgEIcH....
The token is now tied to the chosen projects and can only be used to upload new distribution files.
Using the token in a GitHub Actions workflow
Store the token as a repository secret named PYPI_TOKEN. The following workflow builds a package and uploads it to PyPI using twine.
# .github/workflows/publish.yml
name: Publish to PyPI
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Build distribution
run: |
python -m pip install --upgrade build
python -m build
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python -m pip install --upgrade twine
python -m twine upload dist/*
The environment variables TWINE_USERNAME and TWINE_PASSWORD are the convention expected by twine; the username must be the literal string __token__.
Verifying the token works
- Create a test project on test.pypi.org (e.g.
example‑test‑123). - Generate a scoped token with upload permission for that project only.
- Add the token as a secret in a test repository and run the workflow above (pointing at
--repository-url https://test.pypi.org/legacy/if you prefer). - If the upload succeeds, the token is correctly scoped.
- Return to PyPI, revoke the token, and rerun the workflow. The command should fail with an authentication error such as
401 Client Error: Invalid or expired token, confirming that the token is required for access. - Inspect the token’s details on the API tokens page to verify that only the selected projects and the Upload scope are listed.
Trade‑off and limitation
Scoped tokens reduce the blast radius of a leak, but they cannot restrict uploads to specific versions, file patterns, or dates. The token grants upload rights to the entire selected project until you manually revoke or rotate it. Therefore, treat the token as a powerful credential and rotate it regularly.
Closing action
- Generate a separate scoped token for each package that needs automated publishing.
- Store each token as a secret in the corresponding CI system.
- Set a calendar reminder or use an automation tool to rotate every 90 days (or sooner if you suspect exposure).
- Document the rotation process in your team’s runbook so that revocation and re‑creation are repeatable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.