A CI upload usually starts with an API key copied into GitHub Secrets. Every run uses it until you rotate or revoke it.
GitHub already knows which repository the job belongs to. OpenID Connect, or OIDC, lets the job prove that to the upload service.
Ask GitHub for a token
The job needs permission first:
permissions:
contents: read
id-token: write
id-token: write lets the job request an identity token. The upload service decides whether to accept it.
GitHub gives the job a token endpoint and a temporary credential to call it. The upload tool requests a token with an audience, the intended recipient. GitHub returns a signed JWT containing the repository ID, commit SHA, run ID and expiry.
The tool sends that JWT to the upload API as a bearer token.
Check the signature
A JWT is easy to decode. Anyone can write JSON that says it came from your repository.
The server verifies the signature against GitHub's public keys. It also checks the issuer, audience and expiry. That establishes who issued the token, who it was meant for and whether it is still valid.
Decide what it can upload
A valid token can belong to someone else's repository. The server still needs rules for which repositories it accepts and what each one can do.
For an upload service, that can mean matching the verified repository ID to a connected project, then checking the commit or pull request the upload belongs to. Those checks happen before accepting the files.
A screenshot upload
I use this in stateofpixel, the visual regression testing tool I run. Once the repository is connected, CI can upload screenshots with:
- run: npx stateofpixel upload screenshots
The CLI requests the audience stateofpixel. The server verifies the token, finds the project by repository ID and checks the build's commit, with handling for pull request merge refs.
Then the CLI sends screenshot names and hashes. The server returns upload URLs for images it needs, and the CLI uploads them and reports completion. Each uploaded image is checked against its expected hash. The security docs cover these checks.
What "no secret" means
There is still a credential during the job. Keep the bearer token private until it expires.
What disappears is the long-lived upload key in repository settings. GitHub issues the identity token when the job requests it, and the server checks it on arrival. On CI providers without this integration, stateofpixel uses a project token instead.