
PyPI uses GitHub OpenID Connect short-lived credentials to bind publishing permissions to auditable workflows.
The first time I published a package to PyPI, I followed the official documentation step-by-step. I got the project out the door, but I didn't really understand how it worked under the hood. I just went through the motions.
A few days ago, I needed to publish another package. Once again, I realized I didn't actually understand the publishing flow. Digging through docs took another two hours, leaving me with a sense of accumulated technical debt.
So I decided to figure it out once and for all. How does PyPI actually trust GitHub? How does GitHub convince PyPI to accept a release build? And most surprisingly, why doesn't this entire process require a single PyPI API token?
Traditional PyPI API Tokens can prove that “someone holds an upload credential.” They can't prove which GitHub Workflow performed the upload.
A PyPI API Token is a long-lived shared secret. Developers store it as a GitHub Secret. When the Workflow runs, GitHub injects the secret into the release step. The upload tool includes the Token in its request. PyPI checks whether the Token is valid and authorized to upload to the target project.
This mechanism answers one question: “Who holds the credential?” It can't answer who used the credential or under what execution context.
Rendering Mermaid diagram...
For example, PyPI usually can't determine from the Token alone:
This isn't because PyPI skipped a GitHub lookup. It's a limitation of the Token's design. After a Token is created, it can be copied to a local computer, another CI system, or a different Workflow. As long as the secret remains valid, PyPI has little way to distinguish an expected release from an upload made with a leaked credential.
Think of it as an office key. The lock can confirm that the key is valid. It can't tell where the person using it came from or whether someone copied the key. This analogy illustrates proof of possession, not execution context. An API Token isn't like an access badge with a photo and an expiration time. It doesn't describe the execution context behind a particular request.
API Tokens still have valid use cases. You can use them for local manual releases, non-GitHub CI systems, or automation environments that don't support OpenID Connect (OIDC).
The trade-offs are concrete. You must store, rotate, and revoke the Token. If it appears in logs, build artifacts, or untrusted scripts, an attacker can upload directly until it expires or is revoked. Even if the Token is restricted to a single PyPI project, it still can't identify which GitHub run made the request.
Storing a token as a repository-level secret is often mistaken for limiting the publishing source. It doesn't. A repository-level Secret limits where the configuration is stored, not the identity of the publisher. Any Workflow that can read the Secret—or indirectly invoke a release step that holds it—may still use the key.
Trusted Publishing changes what PyPI verifies. GitHub doesn't log in to PyPI as a user. Instead, PyPI trusts a short-lived identity Token issued by GitHub for the current Workflow run.
Think of an access-control system checking a temporary visitor badge. It must verify that the issuing organization is trusted, that the badge hasn't expired, and that the visitor's details match the appointment. This isn't the same as handing over the company's master key. The badge grants access only for a short period and a specific visit.
PyPI pre-registers the identity constraints for allowed publishers. These constraints can reference a GitHub owner, repository, and Workflow file. During a Workflow run, GitHub provides a short-lived identity Token that describes the current job. PyPI accepts the upload only when the request satisfies all of those constraints.
In your PyPI account settings under Publishing , you can register various OIDC publishing sources.
Rendering Mermaid diagram...
This design binds publishing permissions to the execution context instead of to a copyable secret. A request can't publish merely because it came from GitHub. It must also match the identity registered in PyPI. Otherwise, any GitHub repository could impersonate the publisher.
More importantly, OIDC proves the identity of the automation context. It doesn't prove that the build artifact is safe. An attacker who can modify a trusted Workflow, control a publishable branch, or pass the release approval may still use the legitimate identity to upload a malicious version. Trusted Publishing doesn't replace code review, branch protection, or approval controls.
PyPI doesn't trust a request simply because it came from GitHub. It trusts an identity credential that GitHub issued for a specific workflow and that is still valid.
The previous chapter explained that you must first register the project for publishing under your PyPI account. Once that registration is in place, the corresponding OIDC workflow can upload releases.
In GitHub Actions, id-token: write allows a job to request an OIDC token at runtime. It neither grants write access to the repository nor stores the token in Secrets.
Think of it as a receptionist printing a temporary visitor badge for a registered guest. The badge is generated from the guest's details for that visit and expires after a set period. It is not a permanent key that lets the guest open the building whenever they want.
After a publishing job declares this permission, GitHub's OIDC Provider issues a JSON Web Token (JWT), a signed identity claim, based on the current run context. The publishing Action requests a token for PyPI and exchanges it for the short-lived authorization needed to upload the package.
The JWT includes the Issuer, Audience, and expiration time. It also carries contextual claims such as the repository, workflow, and environment. For the exact claim fields and PyPI's matching rules, refer to GitHub Docs' OpenID Connect reference and PyPI's Trusted Publishers documentation.
The name write is easy to misunderstand. It means “allow this job to request an identity token.” It does not mean “allow this job to modify GitHub's OIDC configuration.”
The trade-off is that any job with id-token: write can request tokens from third-party services that accept GitHub OIDC. Don't grant this permission to unrelated jobs. Don't combine it with untrusted build scripts in the same job. Even without access to a PyPI API Token, such a script could use the job's identity to authenticate to an external service.
PyPI doesn't need to call GitHub and inspect the repository for every release. It first verifies that GitHub issued the JWT. It then checks whether the token's claims match the Trusted Publisher rules saved in PyPI.
Rendering Mermaid diagram...
The verification order matters. PyPI first uses the public signing key of the GitHub OIDC Issuer to verify the JWT's origin and integrity. It also checks the audience and expiration time. Only then does it compare identity claims such as the repository and Workflow.
A common misconception is that PyPI directly reads the GitHub repository or determines authorization from commit information. It doesn't. PyPI verifies the identity claims signed by GitHub and compares them with its own registration rules.
Short-lived tokens reduce the window in which a leaked credential can be abused. But they also require authentication and upload to happen during the same valid run. Network retries, clock skew in the environment, or mismatched PyPI and GitHub configuration can all cause publishing to fail during authentication.
Short-lived credentials don't mean “nothing can go wrong.” They impose stricter timing requirements in exchange for a smaller exposure window.
Split the build and publishing processes into separate jobs to ensure that only the publishing job can request OIDC identity tokens.
In GitHub Actions, permissions: id-token: write controls whether a job can request a JWT from the GitHub OIDC Provider. If you put the build and publish steps in the same job, every step in that job can call the internal API to obtain an identity token.
Use separate jobs instead. The build job doesn't get permission to request OIDC tokens. The publish job declares id-token: write on its own. It downloads the artifact and submits it to PyPI using OIDC authentication.
With this setup, only the publish job can request an OIDC JWT from GitHub. The environment: pypi declaration tells GitHub to include the corresponding Environment claim in the issued JWT. That claim must match the Trusted Publishing rule registered on PyPI.
Never print the JWT in plaintext to verify that the OIDC identity chain works. An OIDC JWT is valid for only a few minutes, but exposing it before expiration still creates a credential-leak risk. Check the GitHub Actions run logs together with the PyPI settings instead.
In the GitHub Actions logs, focus on the publishing Action's authentication stage. If the job lacks id-token: write, the GitHub OIDC Provider will refuse to issue a JWT. If PyPI rejects the authorization exchange, the logs will usually show an HTTP 400 error. This failure typically means that the repository name, workflow path, or Environment name in the JWT doesn't match the registered rule.
Trusted Publishing replaces long-lived static API tokens with short-lived GitHub OIDC JWTs. This removes the risk of a static key leaking from the CI secret store or being stolen and used offline.
But OIDC only verifies which GitHub execution context made the request. It doesn't verify whether the code is safe. If an attacker gains permission to modify the repository, they can still change the workflow and trigger a legitimate OIDC upload.
PyPI uses GitHub OpenID Connect short-lived credentials to bind publishing permissions to auditable workflows.