How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026)
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.
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:
- Protect the environment. Required reviewers plus a deployment branch rule on
prodmeans the subjectenvironment:prodcan only be minted by a reviewed run from an allowed branch. - 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-filescompromise spread. - Keep
id-token: writeon the deploy job only, and split build and deploy into separate jobs so build-time dependencies never run next to cloud credentials. - Scope the cloud role to the resources that job touches. A federated
AdministratorAccessrole is still an administrator. - 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
How to Run an AI Coding Agent in GitHub Actions Safely (2026)
Step-by-step hardening for claude-code-action v1.0.254 and codex-action v1.13: SHA pinning, least-privilege permissions, safe triggers, keeping prompt injection from issues away from secrets, tool allowlists, turn caps, rulesets and troubleshooting.
15 minOct 20, 2026How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026)
Step-by-step guide to secret scanning in 2026: gitleaks 8.30.1 and TruffleHog 3.99.3 as pre-commit hooks, GitHub push protection, CI scanning with pinned actions, rotating a leaked key, and removing it from history with git filter-repo.
14 minOct 20, 2026AI Agent Failures in Production: 5 Incidents, 14 Failure Modes (2026)
What actually goes wrong when AI agents run in production: the Replit, Amazon Kiro, PocketOS and PromptPwnd incidents, the MAST taxonomy of 14 failure modes from 1,642 traces, cost loops, permission mistakes, and the guardrails that stopped them.
12 min