OIDC Token Exchange
OIDC token exchange is the pattern where a workflow presents a short lived identity token to a cloud provider and receives temporary credentials back.
OIDC token exchange is the pattern where a workload presents a short lived identity token, signed by an identity provider the other side already trusts, and receives temporary credentials scoped to what that identity is allowed to do. In GitHub Actions it means a job asks GitHub for an OpenID Connect token describing the repository, branch, and workflow it is running from, then trades that token with a cloud provider for credentials that expire on their own, so the repository stores no long lived access key.
The exchange replaces a stored secret with a stated identity. What used to be an access key ID and a secret access key sitting in repository secrets becomes a trust policy on the cloud side that names one repository and one branch.
Definition
Three parties take part in every OIDC token exchange, whatever the platform.
- The workload. The thing that needs credentials. In GitHub Actions this is a single job.
- The identity provider. The service that mints and signs a token describing the workload. For GitHub Actions the issuer is
https://token.actions.githubusercontent.com. - The relying party. The service holding the resources. It validates the token signature against the issuer's published keys and compares the token claims against a policy that was configured once, in advance.
The generic mechanism is standardized. OpenID Connect defines the token format and the discovery endpoint where a relying party fetches the issuer's signing keys, and OAuth 2.0 Token Exchange (RFC 8693) defines the request shape for trading one token for another. Each cloud provider brands its implementation differently: AWS calls it web identity federation, Azure and Google Cloud both call it workload identity federation. The steps underneath are the same three.
The property that makes the pattern worth adopting is directionality. The relying party pulls the issuer's public signing keys over the network and verifies the token itself. Nothing secret travels from the cloud provider into the repository, so there is nothing in the repository to leak, rotate, or forget to revoke when a contributor leaves.
What the identity token contains
The token GitHub issues is a signed JSON Web Token. The claims below are the ones a trust policy usually reads (GitHub documentation on security hardening with OpenID Connect, checked on 2026-08-13).
| Claim | Value it carries | Why a policy reads it |
|---|---|---|
iss | https://token.actions.githubusercontent.com | Identifies which issuer signed the token |
sub | repo:OWNER/REPO:ref:refs/heads/main and similar | The main condition a trust policy matches on |
aud | Defaults to the repository owner URL, overridable per request | Stops a token minted for one service from being replayed at another |
repository | OWNER/REPO | Restricts the exchange to one repository |
repository_owner | OWNER | Restricts the exchange to one organization |
ref | refs/heads/main, refs/tags/v1.2.0 | Restricts the exchange to one branch or tag |
environment | The deployment environment name, when the job declares one | Ties production credentials to a protected environment |
job_workflow_ref | Path and ref of the workflow file that ran the job | Restricts the exchange to one reusable workflow |
actor | The account that triggered the run | Audit trail on the cloud side |
sha, run_id, run_attempt | Commit and run identifiers | Correlating a credential back to a run |
How the subject claim scopes the exchange
sub is the claim most trust policies key on, and its format varies with what triggered the job:
repo:example-org/example-repo:ref:refs/heads/main
repo:example-org/example-repo:ref:refs/tags/v1.2.0
repo:example-org/example-repo:environment:production
repo:example-org/example-repo:pull_requestAn exact string comparison on one of those values is what limits a role to one repository and one branch. Loosening the comparison to a wildcard over the owner, such as a prefix match on repo:example-org/, hands the same role to every repository in the organization, including a repository a contributor creates tomorrow. Match the full subject, and add a second condition on aud.
What the job needs before it can ask
The identity token request depends on one grant in the workflow file:
permissions:
id-token: writeid-token accepts write or none, with no read level, because requesting a token is the only operation the scope covers. When the grant is present, the runner exposes ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN to the steps in that job, and an action calls that endpoint to mint a token for whatever audience it asks for. When the grant is absent, both variables are missing and the request fails before it reaches GitHub. A token can be requested only while the job is running, so nothing persists after the job ends.
Example
This workflow deploys from main. The deploy job asks for an identity token, hands it to AWS, and gets back temporary credentials that work for that job alone.
name: deploy
on:
push:
branches: [main]
permissions: {}
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy
aws-region: us-east-1
- name: Confirm the assumed identity
run: aws sts get-caller-identity
- run: aws s3 sync ./dist s3://example-artifacts/site/ --deletepermissions: {} at the top of the file sets every scope to none for any job that stays silent. The deploy job then restates the two grants it needs. contents: read lets actions/checkout fetch the commit, and id-token: write is what makes the credential step possible.
The other half of the configuration lives in AWS and is written once. The role's trust policy names the GitHub issuer as a federated principal and pins both aud and sub:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main"
}
}
}
]
}With both halves in place, a push to main produces this sequence:
- GitHub starts the
deployjob and injects the twoACTIONS_ID_TOKEN_REQUEST_*variables, because the job holdsid-token: write. - The credentials action calls the request URL with audience
sts.amazonaws.comand receives a signed JWT whosesubisrepo:example-org/example-repo:ref:refs/heads/main. - The action calls
sts:AssumeRoleWithWebIdentitywith that JWT and the role ARN. - AWS fetches GitHub's public signing keys from the issuer's discovery endpoint, verifies the signature, then compares
audandsubagainst the two conditions in the trust policy. - AWS returns a temporary access key, secret key, and session token. The default session lasts one hour, and the role's maximum session duration caps it (AWS STS API reference, checked on 2026-08-13).
- The action exports those three values as environment variables, so
aws sts get-caller-identityandaws s3 syncauthenticate as the role. When the job ends, the session expires on its own.
A push to any branch other than main changes sub, step 4 fails the string comparison, and AWS returns an error instead of credentials. The branch restriction is enforced by the cloud provider against a claim GitHub signed, so editing the workflow file cannot widen it.
Exchanging with a provider that has no ready made action
When the relying party is an internal service or a provider without a published action, request the token directly and post it wherever that service expects it:
- name: Request an identity token
run: |
curl --silent --fail \
--header "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://vault.example.com" \
| jq -r '.value' > "${RUNNER_TEMP}/id-token"The audience string has to match what the receiving service expects, because that service compares aud before it looks at anything else. Write the token to a file under RUNNER_TEMP rather than into a step output, so the value stays off the log stream and out of the run's artifacts.
Related Terms
- How to authenticate to AWS from GitHub Actions with OIDC: the identity provider registration, the role trust policy, and the errors each misconfiguration produces.
- GITHUB_TOKEN, the per job credential the id-token grant sits alongside: where the job's GitHub credential comes from, what it reaches, and how long it lasts.
- Managing secrets in GitHub Actions workflows: repository, environment, and organization secrets, and which of them this pattern removes.
- WarpBuild runner security documentation: how runner isolation, storage, and secrets handling are documented.
- AWS account configuration for BYOC runners: the VPC, subnet, security group, and IAM prerequisites for running jobs in your own AWS account.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What is OIDC token exchange in GitHub Actions?
A job asks GitHub's OpenID Connect provider for a signed identity token that describes the repository, branch, and workflow it is running from. It sends that token to a cloud provider, which validates the signature and compares the claims against a trust policy, then returns temporary credentials. No long lived cloud key is stored in the repository.
Why does the identity token request fail inside my job?
The job is missing the id-token write grant. GitHub injects the ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variables only when the job holds id-token write, and naming any scope in a permissions block sets every scope left out to none, so a workflow level block that lists other scopes removes it.
How is the exchange restricted to one branch?
Through the sub claim. GitHub sets sub to a string such as repo:example-org/example-repo:ref:refs/heads/main, and the trust policy on the cloud side compares it with an exact string match. A push to any other branch produces a different sub, so the comparison fails and no credentials are issued.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.