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).

ClaimValue it carriesWhy a policy reads it
isshttps://token.actions.githubusercontent.comIdentifies which issuer signed the token
subrepo:OWNER/REPO:ref:refs/heads/main and similarThe main condition a trust policy matches on
audDefaults to the repository owner URL, overridable per requestStops a token minted for one service from being replayed at another
repositoryOWNER/REPORestricts the exchange to one repository
repository_ownerOWNERRestricts the exchange to one organization
refrefs/heads/main, refs/tags/v1.2.0Restricts the exchange to one branch or tag
environmentThe deployment environment name, when the job declares oneTies production credentials to a protected environment
job_workflow_refPath and ref of the workflow file that ran the jobRestricts the exchange to one reusable workflow
actorThe account that triggered the runAudit trail on the cloud side
sha, run_id, run_attemptCommit and run identifiersCorrelating 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_request

An 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: write

id-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/ --delete

permissions: {} 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:

  1. GitHub starts the deploy job and injects the two ACTIONS_ID_TOKEN_REQUEST_* variables, because the job holds id-token: write.
  2. The credentials action calls the request URL with audience sts.amazonaws.com and receives a signed JWT whose sub is repo:example-org/example-repo:ref:refs/heads/main.
  3. The action calls sts:AssumeRoleWithWebIdentity with that JWT and the role ARN.
  4. AWS fetches GitHub's public signing keys from the issuer's discovery endpoint, verifies the signature, then compares aud and sub against the two conditions in the trust policy.
  5. 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).
  6. The action exports those three values as environment variables, so aws sts get-caller-identity and aws s3 sync authenticate 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.

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.