How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

This guide is for anyone who still has AWS_SECRET_ACCESS_KEY, a Google service account JSON file or an Azure client secret sitting in GitHub repository secrets and wants them gone. By the end you will have GitHub Actions workflows that authenticate to AWS, Google Cloud and Azure with OpenID Connect, trust configurations pinned to a specific repository and environment, and a checklist for the errors everyone hits on the first run. Everything was checked against current docs on 20 October 2026, using aws-actions/configure-aws-credentials v6.3.0, google-github-actions/auth v3.0.0, azure/login v3.1.0 and actions/checkout v7.0.1. You need admin rights on the GitHub repository and permission to create IAM roles, workload identity pools or Entra app registrations in the target cloud. If you want the background on why this matters, read Workload Identity Federation Explained first.

How GitHub Actions OIDC works

When a job has the id-token: write permission, the runner can ask GitHub's OIDC provider for a JSON Web Token. The token is signed by https://token.actions.githubusercontent.com and carries claims about the run: repository, ref, environment, workflow, actor and a sub (subject) that combines the important ones. The cloud action sends that token to the cloud's security token service, which checks the signature against GitHub's published keys, checks the audience and subject against your trust configuration, and returns credentials that expire on their own.

GitHub Actions OIDC: one token, three audiences, three failure messages 1. GitHub job permissions: id-token: write requests JWT for an aud 2. GitHub OIDC provider token.actions.githubusercontent signs iss, aud, sub, repo IDs valid for a single job AWS STS AssumeRoleWithWebIdentity aud: sts.amazonaws.com sub mismatch: Not authorized to perform sts:AssumeRoleWithWebIdentity Google Cloud STS aud: provider resource name condition fails: Failed to generate Google Cloud federated token Microsoft Entra ID aud: api://AzureADTokenExchange no exact subject match: AADSTS700213 Short-lived credentials come back; nothing is stored in repo secrets. Audiences and errors: AWS IAM, google-github-actions/auth, azure/login docs
The same GitHub token is requested with a different audience per cloud, and each cloud reports a subject or condition mismatch with its own message. Sources: GitHub OIDC reference, AWS IAM docs, the google-github-actions/auth troubleshooting guide and the azure/login README.

You can see GitHub's side of this without any account. The discovery document is public:

curl -s https://token.actions.githubusercontent.com/.well-known/openid-configuration \
  | jq '{issuer, jwks_uri, alg: .id_token_signing_alg_values_supported}'

When I ran it today the output was:

{
  "issuer": "https://token.actions.githubusercontent.com",
  "jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks",
  "alg": [
    "RS256"
  ]
}

Step 1: Decide which subject your cloud will trust

The sub claim is what your trust policy matches, so decide it before touching any cloud. GitHub's OIDC reference documents the default formats:

# Job that references an environment (recommended for deploys)
repo:octo-org/octo-repo:environment:prod

# Push to a branch or tag, no environment
repo:octo-org/octo-repo:ref:refs/heads/main
repo:octo-org/octo-repo:ref:refs/tags/v1.4.2

# pull_request event, no environment
repo:octo-org/octo-repo:pull_request

# Immutable format: default for repos created after 15 July 2026,
# and for any repo renamed or transferred after that date
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main

Two rules decide most of your design. First, if a job references an environment, the subject uses the environment, not the branch; this catches out a lot of people whose branch-based trust policy stops working the moment they add environment: prod. Second, since July 2026 GitHub appends immutable owner and repository IDs for new repositories, and existing ones keep the old format until you opt in. Ask GitHub which format your repository emits instead of guessing:

# Requires repo admin; returns use_default, include_claim_keys,
# use_immutable_subject and sub_claim_prefix
gh api repos/OWNER/REPO/actions/oidc/customization/sub

# Opt an existing repository into immutable subjects
gh api -X PUT repos/OWNER/REPO/actions/oidc/customization/sub \
  -F use_default=true -F use_immutable_subject=true

The use_immutable_subject field and sub_claim_prefix response are documented in the REST reference. If you opt in, update every cloud trust configuration first (or add a second one alongside), because a trust policy written for the old subject will not match the new one.

My default is one cloud role per environment, trusted only for environment:<name> subjects, with the GitHub environment protected by required reviewers and a deployment branch rule. Read-only roles for plan or test jobs can trust ref:refs/heads/main or pull_request separately.

Step 2: Grant id-token: write only where it is needed

Without this permission the runner never receives the variables needed to request a token, and every cloud action fails. Set it on the deploy job, not the whole workflow:

permissions:
  contents: read          # workflow default for every job

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: prod     # subject becomes repo:ORG/REPO:environment:prod
    permissions:
      contents: read      # needed by actions/checkout
      id-token: write     # lets this job request the OIDC JWT; grants no repo write access

GitHub notes that id-token: write does not let the workflow modify anything; it only allows fetching an OIDC token. Two edge cases: reusable workflows in another organisation need id-token: write set explicitly in the caller, and pull requests from forks typically cannot be granted write permissions at all, so OIDC will not work there unless an admin enables "Send write tokens to workflows from pull requests", which I would not.

Step 3: Configure AWS (IAM OIDC provider and role)

Create the identity provider once per AWS account. The thumbprint is optional: IAM verifies GitHub's JWKS endpoint against its own trusted root CAs and only falls back to thumbprints if it cannot (AWS docs).

# AWS CLI v2; run once per account
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com

Write the trust policy to trust-prod.json. Use the subject from Step 1 exactly; this example uses the immutable format with an environment:

{
  "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:octo-org@123456/payments-api@456789:environment:prod"
        }
      }
    }
  ]
}

IAM refuses to save a trust policy for GitHub's provider that lacks a token.actions.githubusercontent.com:sub condition, or whose value is only a wildcard (AWS docs). Use StringLike only if you genuinely need a pattern, such as repo:octo-org/payments-api:* for a read-only role. Create the role and attach a narrowly scoped policy:

aws iam create-role \
  --role-name gha-payments-api-prod \
  --assume-role-policy-document file://trust-prod.json \
  --max-session-duration 3600

aws iam attach-role-policy \
  --role-name gha-payments-api-prod \
  --policy-arn arn:aws:iam::111122223333:policy/payments-api-deploy

Then the workflow. Actions are pinned to full commit SHAs, with the tag in a comment:

name: deploy-aws
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: prod
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::111122223333:role/gha-payments-api-prod
          aws-region: eu-west-1
          role-session-name: gha-${{ github.run_id }}
          allowed-account-ids: "111122223333"   # fail if creds arrive for another account
      - run: aws sts get-caller-identity

The allowed-account-ids input arrived in v5 and is cheap insurance. Note that v6 runs on Node 24 and needs runner v2.327.1 or later, which matters if you run old self-hosted runners. The audience defaults to sts.amazonaws.com; you only set audience for non-default partitions such as the China regions.

Step 4: Configure Google Cloud (workload identity pool and provider)

Google's deployment pipeline guide is blunt: GitHub uses one issuer for every organisation, so you must add an attribute condition. It also recommends mapping the numeric repository_id and repository_owner_id claims, which cannot be squatted the way names can. Find your IDs with gh api repos/OWNER/REPO --jq '.id, .owner.id'.

# gcloud CLI; PROJECT_ID is the project ID, PROJECT_NUMBER the number
gcloud iam workload-identity-pools create github \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="GitHub Actions"

gcloud iam workload-identity-pools providers create-oidc payments-api \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="github" \
  --display-name="payments-api repo" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository_id=assertion.repository_id,attribute.repository_owner_id=assertion.repository_owner_id,attribute.environment=assertion.environment" \
  --attribute-condition="assertion.repository_owner_id == '123456' && assertion.repository_id == '456789'"

Every claim you test in the condition or bind on in IAM must also be mapped. Next, grant access. Google's preferred model is direct resource access, binding the role to the federated principal set with no service account in between:

# Allow only the prod environment of repo 456789 to write to one bucket
gcloud storage buckets add-iam-policy-binding gs://payments-api-artifacts \
  --role="roles/storage.objectAdmin" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github/attribute.environment/prod"

Binding on attribute.environment alone is safe here only because the provider's condition already restricts admission to one repository. If several repositories share a pool, bind on attribute.repository_id and enforce the environment in the condition instead. Some Google APIs still do not accept federated principals; for those, grant roles/iam.workloadIdentityUser on a service account and pass service_account to the action.

name: deploy-gcp
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: prod
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1  (must run before auth)
      - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
        with:
          project_id: my-project-id
          workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/github/providers/payments-api
      - run: gcloud storage cp ./dist/app.tar.gz gs://payments-api-artifacts/

The workload_identity_provider must be the full provider name with the project number, not the pool name and not the project ID. The action's troubleshooting guide also warns that pool, provider and IAM changes can take up to five minutes to propagate, so wait before you start debugging.

Step 5: Configure Azure (Entra app and federated credential)

On Azure you add a federated identity credential to an app registration or a user-assigned managed identity. Entra ID matches issuer and subject exactly, supports no wildcards, and allows 20 federated credentials per app (Microsoft docs), so plan one credential per environment.

# Azure CLI 2.78.0
APP_ID=$(az ad app create --display-name gha-payments-api-prod --query appId -o tsv)
az ad sp create --id "$APP_ID"

cat > credential.json <<'EOF'
{
  "name": "payments-api-prod",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:octo-org/payments-api:environment:prod",
  "description": "GitHub Actions prod environment",
  "audiences": ["api://AzureADTokenExchange"]
}
EOF
az ad app federated-credential create --id "$APP_ID" --parameters credential.json

# Scope the role to one resource group, not the subscription
az role assignment create \
  --assignee "$APP_ID" \
  --role "Contributor" \
  --scope "/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/rg-payments-prod"

The subject above is the name-only format. If your repository uses immutable subjects, put the repo:octo-org@123456/payments-api@456789:... form in the credential instead, and if you are migrating, follow Microsoft's immutable subject guide: create the new credential, opt in, verify, then delete the old one. Store the IDs as variables; they are identifiers, not secrets.

name: deploy-azure
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: prod
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - run: az group show --name rg-payments-prod --query id -o tsv

The azure/login README warns against feeding github.event values such as branch names or PR titles into these inputs, because that lets an attacker influence which identity you log in as. Keep them as fixed variables or secrets.

Step 6: Lock it down

OIDC removes the stored key. It does not stop a malicious step in the same job from using the token or the credentials it produces. A short checklist:

  1. Protect the environment. Required reviewers plus a deployment branch rule on prod means the subject environment:prod can only be minted by a reviewed run from an allowed branch.
  2. Pin every third-party action to a commit SHA, as in the examples above. Tags can be moved, which is exactly how the 2025 tj-actions/changed-files compromise spread.
  3. Keep id-token: write on the deploy job only, and split build and deploy into separate jobs so build-time dependencies never run next to cloud credentials.
  4. Scope the cloud role to the resources that job touches. A federated AdministratorAccess role is still an administrator.
  5. Delete the old static keys once the OIDC path works, and confirm in CloudTrail, Cloud Audit Logs or Entra sign-in logs that nothing still uses them.

The GitHub Actions security post covers runner hardening in more depth, and the secret scanning guide catches any keys that sneak back in.

Step 7: Debug by printing the claims, not the token

Almost every failure is a mismatch between what the token says and what the trust configuration expects. Print the claims in a throwaway job. This decodes only the payload; the token itself is never echoed:

  debug-claims:
    runs-on: ubuntu-24.04
    environment: prod            # match the job you are debugging
    permissions:
      id-token: write
    steps:
      - name: Show OIDC claims
        run: |
          TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
          echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
            | jq '{sub, aud, repository, repository_id, repository_owner_id, environment, ref, job_workflow_ref}'

The ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN variables are the documented mechanism custom actions use. If base64 -d complains about padding, the output is usually still complete; jq will tell you if it is not. Compare sub and aud character by character with your trust configuration, including case. On Azure, azure/login already prints the subject under "Federated token details" in the run log.

Troubleshooting

Symptom Likely cause Fix
AWS: "It looks like you might be trying to authenticate with OIDC. Did you mean to set the id-token permission?" Job lacks id-token: write, or it was set at workflow level and overridden at job level Add id-token: write to the job's permissions block
AWS: "Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity" sub does not match: environment vs branch, pull_request subject, immutable vs name-only format, or case Print the claims (Step 7) and copy the exact sub into the trust policy
AWS: "Incorrect token audience" Token aud is not in the provider's client ID list, or the trust policy checks a different aud Add sts.amazonaws.com to the provider and policy, or set the action's audience input to match
AWS: "No OpenIDConnect provider found in your account for https://token.actions.githubusercontent.com" Provider missing in the role's account, or the role ARN points at a different account Create the provider in the same account as the role; check role-to-assume
GCP: "GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN..." Missing id-token: write, or a pull request from a fork Add the permission; do not run cloud auth on fork PRs
GCP: "Failed to generate Google Cloud federated token" Attribute condition rejected the token, condition uses an unmapped claim, wrong casing, or project ID instead of number Map every claim you test, fix casing, use the full provider name with the project number
GCP: "Failed to generate OAuth 2.0 Access Token" Service account impersonation binding is wrong Grant roles/iam.workloadIdentityUser to the right principalSet:// on the service account
GCP: "The size of mapped attribute exceeds the 127 bytes limit" Long repository or branch name in a mapped claim such as sub Map shorter claims (IDs, environment) or shorten names
Azure: AADSTS700213 or AADSTS7002138 "No matching federated identity record found" Subject differs from the credential: case, environment vs branch, or numeric IDs present on one side only Copy the "subject claim" from the azure/login log into the credential exactly
Worked yesterday, fails after repo rename or transfer After 15 July 2026, renames and transfers switch the repo to immutable subjects Update trust policies to the repo:org@ID/repo@ID:... format

FAQ

Do I still need the OIDC thumbprint for AWS?

No, not for GitHub. AWS verifies the JWKS endpoint's TLS certificate against its library of trusted root CAs and only uses thumbprints as a fallback, and the --thumbprint-list flag on create-open-id-connect-provider is optional. Existing Terraform that sets one keeps working.

Is the GitHub OIDC token a secret?

Treat it like one while it is valid. It is a bearer token that can be exchanged for cloud credentials if it matches a trust policy, but it is scoped to one job and expires on its own. Never print the raw token to logs; print decoded claims instead.

Does GitHub Actions OIDC work with self-hosted runners?

Yes, the token request works the same way. The risk is different: anything else running on that machine can request tokens or read credentials during your job, so use ephemeral runners, one job per runner, and keep deploy roles narrow.

Can pull requests from forks use OIDC?

Normally no. GitHub caps fork pull request permissions at read, so id-token: write is not granted unless an admin enables write tokens for fork workflows. That is the right default; run cloud deploys on push to protected branches instead.

When a new pipeline fails, the first thing I check is the decoded sub against the trust policy, because in my experience that explains most failures before anything else does. In production I keep one role per environment, trusted only for environment:<name>, protected by reviewers, with actions pinned to SHAs and the old keys deleted rather than "kept just in case". The official references are GitHub's OIDC reference, the AWS GitHub OIDC role guide, Google's deployment pipeline federation guide and Microsoft's federated credential docs.

Related: CI/CD in 2026: Agents Opened 1M PRs in 5 Months, Who Is This Agent? Non-Human Identity in 2026 and CRA Reporting Went Live on 11 September.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related