Trusted Publishing & Attestations

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

API tokens are long-lived secrets that CI has to store and that leak. Trusted Publishing replaces them: PyPI trusts a specific CI workflow, and each run exchanges a short-lived identity token for an upload token. Attestations (PEP 740) go further and let anyone verify which workflow published each file. This page describes both as documented on 2026-10-22. Publishing through them needs a real CI run against PyPI and is not run here; the verification and the public endpoints shown were run against PyPI on that date. The workflows themselves are on GitHub Actions & GitLab CI Release Workflows, and a first introduction is on Virtual Environments & Packaging.

The CI job requests an OIDC token, exchanges it at PyPI’s mint-token endpoint for a 15-minute API token, signs the files with Sigstore and uploads files and attestations

How it works

  1. The CI job asks its provider (GitHub, GitLab, Google Cloud, ActiveState) for an OIDC token, a JWT signed by the provider whose claims identify the run: repository, owner, workflow file, environment.

  2. It posts that token to PyPI’s /_/oidc/mint-token endpoint.

  3. PyPI verifies the signature and matches the claims against the trusted publishers configured on projects.

  4. If a publisher matches, PyPI returns an API token valid for 15 minutes, scoped to the matching projects.

  5. The job uploads with it like with any token (__token__ and the token).

Long-lived tokens can be used by an attacker until someone notices; a minted token expires within minutes, is never stored, and cannot be created outside the configured workflow. Trusted Publishing launched for GitHub on 2023-04-20, was extended to GitLab, Google Cloud and ActiveState on 2024-04-17, and twine has built-in support since 6.1.0 (2025-01-17, "preliminary"). Poetry has none (python-poetry/poetry#7940).

The audience each index expects is public:

curl https://pypi.org/_/oidc/audience
curl https://test.pypi.org/_/oidc/audience
{"audience":"pypi"}
{"audience":"testpypi"}

Adding a publisher

On PyPI, Your projects → Manage → Publishing (or, for a project that does not exist yet, Your account → Publishing to add a pending publisher). What each provider needs:

Provider Configuration

GitHub Actions

Repository owner, repository name, workflow file name (for example release.yml) and, optionally but strongly recommended, a GitHub environment (pypi) on which you can require manual approval.

GitLab CI/CD

Namespace, project, the top-level pipeline file (.gitlab-ci.yml) and optionally an environment. The documentation supports only projects on gitlab.com; the PyPI blog announced GitLab Self-Managed support in beta on 2025-11-10, so check both before relying on a self-managed instance.

Google Cloud

The service account’s email (not the default Compute Engine or App Engine accounts) and optionally its numeric subject.

ActiveState

The ActiveState organisation, project and the publishing user.

Pending publishers create the project on first use and then become normal publishers. They do not reserve the name: if someone else registers it first, the pending publisher is invalidated. If the upload’s metadata names a different project than the pending publisher, PyPI creates the project but rejects the files with:

Non-user identities cannot create new projects. This was probably caused by
successfully using a pending publisher but specifying the project name
incorrectly (either in the publisher or in your project's metadata).
Please ensure that both match.

Fix the metadata, or delete the wrongly named project and create a pending publisher with the right name.

Using a publisher (not run here)

GitHub Actions, the easy way: the PyPA action does the exchange when the job has id-token: write:

jobs:
  pypi-publish:
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write          # job level, never workflow level
    steps:
      # download the dist/ artefact built by a separate job here
      - uses: pypa/gh-action-pypi-publish@release/v1

GitLab CI/CD: request an ID token with id_tokens and let twine (6.1 or later) do the exchange when no token is configured:

publish-job:
  stage: deploy
  image: python:3.14-slim
  id_tokens:
    PYPI_ID_TOKEN:
      aud: pypi
  script:
    - python -m pip install -U twine
    - twine upload dist/*

Google Cloud: python -m id pypi (the id package) prints an OIDC token for the ambient service account; post it to mint-token and give the result to twine.

The manual exchange (internals, subject to change; use the action instead):

resp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi")
oidc_token=$(jq -r '.value' <<< "${resp}")
resp=$(curl -X POST https://pypi.org/_/oidc/mint-token -d "{\"token\": \"${oidc_token}\"}")
api_token=$(jq -r '.token' <<< "${resp}")
echo "::add-mask::${api_token}"
TWINE_USERNAME=__token__ TWINE_PASSWORD="${api_token}" twine upload dist/*

What mint-token answers to something that is not a JWT (sent from this machine):

{"errors":[{"code":"invalid-payload","description":"malformed JWT"}],"message":"Token request failed"}

Security model and restrictions

  • Two-phase exchange: the OIDC token is only traded for a normal PyPI token, which keeps provider-specific failure modes out of PyPI’s authentication code, reuses its macaroon-based tokens, and keeps the pypi- prefix that GitHub’s secret scanning uses to revoke leaked tokens.

  • Account resurrection: if a GitHub account or repository is deleted and its name re-registered, the new owner must not inherit the publisher; PyPI also checks immutable claims such as repository_owner_id.

  • The workflow is the trust boundary: anyone who can run the configured workflow, with its environment, can publish. Protect the environment (required reviewers), restrict who can push tags, and keep id-token: write on the publish job only.

  • Forbidden triggers: PyPI refuses tokens from pull_request_target and issue_comment runs, which have been abused to publish from attacker-controlled code. Reusable workflows cannot be the configured workflow. Registering publishers is rate-limited to 100 per user or IP address per 24 hours.

Errors from mint-token:

Code Meaning

not-enabled

Trusted Publishing is disabled on the index.

invalid-payload

The request body is not {"token": "…"} (or, with unknown trusted publishing issuer, the provider is not supported, for example a self-managed GitLab).

invalid-token

The JWT is malformed, badly signed or expired.

invalid-pending-publisher, invalid-publisher

A valid token that matches no (pending) publisher: check owner, repository, workflow file name and environment; a renamed repository is a common cause.

Attestations (PEP 740)

A digital attestation binds one distribution file (by its SHA-256 digest) to the identity that published it. PyPI accepts in-toto statements with two predicates, at most one of each per file:

  • PyPI Publish (https://docs.pypi.org/attestations/publish/v1): "this file was published by this trusted publisher";

  • SLSA Provenance v1: where and how the file was built.

They are signed keylessly with Sigstore: the CI’s OIDC identity gets a short-lived certificate, the signature is recorded in a public transparency log, and no signing key exists to leak. Only trusted publisher identities are accepted (GitHub, GitLab, Google Cloud, ActiveState). PyPI has supported them since 2024-11-14.

Producing them:

  • the PyPA action does it by default since v1.11.0 (2024-10-30);

  • python -m pypi_attestations sign dist/* in a job with id-token: write, then twine upload --attestations dist/*, which picks up <file>.publish.attestation files next to each distribution (twine refuses --attestations for indexes other than PyPI and TestPyPI).

Consuming them: the simple index and the Integrity API serve a provenance object per file, grouping the attestations by publisher. Verifying a real file on PyPI with pypi-attestations 0.0.30:

pypi-attestations verify pypi --repository https://github.com/pypa/sampleproject \
  https://files.pythonhosted.org/packages/d7/73/c16e5f3f0d37c60947e70865c255a58dc408780a6474de0523afd0ec553a/sampleproject-4.0.0-py3-none-any.whl
OK: sampleproject-4.0.0-py3-none-any.whl

Claiming the wrong repository fails with exit code 1:

Verification failed: provenance was signed by repository "pypa/sampleproject", expected "pypa/pip"

The provenance object behind it (https://pypi.org/integrity/sampleproject/4.0.0/sampleproject-4.0.0-py3-none-any.whl/provenance, decoded) names the publisher {"kind": "GitHub", "repository": "pypa/sampleproject", "workflow": "release.yml", "environment": ""} and one statement with the predicate type https://docs.pypi.org/attestations/publish/v1. Files published through Trusted Publishing also get their repository URLs shown as verified on the project page.