In a nutshell
Imagine a courier who needs to enter three different secure buildings — AWS, Azure, and GCP — to drop off a deployment. The old way was to cut the courier a permanent master key for each building and let them keep it in their pocket forever. Keys get copied, dropped, and photographed; anyone who finds one walks straight in, and nobody ever remembers to change the locks. That pocket full of master keys is exactly what AWS_ACCESS_KEY_ID and friends are when they sit in your GitHub repo secrets.
OpenID Connect (OIDC) replaces the master keys with a signed, single-use day pass. When a GitHub Actions job runs, GitHub’s identity service prints a fresh badge — a JWT (a signed JSON token) — that says, in effect, “the bearer is a job from my-org/my-repo, on branch main, deploying to the prod environment, and this badge is only valid for the next few minutes.” Each building’s front desk (the cloud’s trust configuration) has a rule: “accept a badge only if it was printed by GitHub and says exactly main and is for the prod environment.” If the badge matches, the desk hands over a temporary visitor pass (short-lived credentials, ~1 hour) scoped to just the rooms the courier needs. Nothing is stored. There is no master key to steal.
That is the whole idea: GitHub mints a short-lived signed token, the cloud verifies it against GitHub’s public keys and your trust rules, and swaps it for temporary credentials. No secret ever lives in the repo. The rest of this lesson is the mechanics — how the token is built, how each cloud’s front desk is configured, and how to write the matching rules tightly enough that only your pipeline (and nobody else’s) gets in.
Level: Advanced · Time: ~25 min
Prerequisites: You should be comfortable with how a GitHub Actions workflow is structured — jobs, steps, permissions, and secrets — from GitHub Actions fundamentals, and it helps to have seen reusable workflows since caller-pinning is one of the sharper controls here. The broader theme — replacing standing secrets with short-lived, on-demand credentials — is the same one behind Vault dynamic secrets. A rough grasp of what a JWT is (a base64 header, payload of claims, and a signature) will make section 1 click faster.
After this lesson you will be able to:
- Explain how GitHub mints and signs a per-job OIDC token, and read its claims (
iss,aud,sub,ref,environment,job_workflow_ref) to know exactly what your trust policy can match on. - Federate a single workflow to AWS (IAM OIDC provider + a role trusted via
sts:AssumeRoleWithWebIdentity), Azure (a federated identity credential on a user-assigned managed identity), and GCP (a Workload Identity Federation pool + provider) with zero stored keys. - Scope trust by
repo,ref, tag, andenvironmentusing thesubclaim, and pin the reusable-workflow caller viajob_workflow_ref. - Pin the
audclaim to shut the confused-deputy hole, and write a negative test that proves the trust fails closed. - Migrate an existing key-based fleet to keyless without breaking pipelines, then scrub and guard against re-introduced key secrets.
- Recognise why long-lived access keys are the anti-pattern, and articulate the security model to a reviewer.
Read the diagram left → right: the job asks GitHub for a token with id-token: write; GitHub’s OIDC issuer signs a short-lived JWT carrying sub/aud/ref claims; each cloud’s trust config (AWS IAM role, Azure federated credential, GCP WIF pool) verifies the signature against GitHub’s public keys and matches the claims; the STS exchange then swaps the token for temporary credentials that auto-expire — no secret is ever stored, which is exactly what the red node contrasts against long-lived keys.
Stored cloud access keys are the single biggest credential-leak surface in most CI estates. They sit in repo secrets, get copied into forks, leak through set -x, and never rotate. OpenID Connect (OIDC) removes them entirely: GitHub mints a short-lived, signed JWT per job, the cloud verifies it against GitHub’s public keys, and exchanges it for temporary credentials scoped by a trust policy you control. No secret is ever stored.
This guide wires one workflow to all three major clouds keylessly, then locks the trust down to specific branches, tags, environments, and reusable-workflow callers, and finishes with an audit and a zero-downtime migration off your existing keys.
1. How GitHub mints and signs the job token
When a job sets permissions: id-token: write, the runner can call GitHub’s OIDC token endpoint and receive a JWT signed by GitHub’s OIDC provider. The issuer is fixed:
https://token.actions.githubusercontent.com
The cloud provider fetches GitHub’s JWKS from /.well-known/openid-configuration at that issuer, validates the signature, then evaluates claims against your trust policy. The claims you care about for scoping:
| Claim | Example value | Use for |
|---|---|---|
iss |
https://token.actions.githubusercontent.com |
Identifying the provider |
aud |
sts.amazonaws.com (configurable) |
Anti-confused-deputy audience pin |
sub |
repo:my-org/my-repo:environment:prod |
Primary scoping handle |
repository |
my-org/my-repo |
Repo-level binding |
repository_owner |
my-org |
Org-level binding |
ref |
refs/heads/main |
Branch/tag binding |
environment |
prod |
Environment-gated binding |
job_workflow_ref |
my-org/.github/.github/workflows/deploy.yml@refs/heads/main |
Reusable-workflow caller binding |
The sub claim is the workhorse. Its format changes based on context:
- Default (branch push):
repo:my-org/my-repo:ref:refs/heads/main - Tag:
repo:my-org/my-repo:ref:refs/tags/v1.2.3 - Environment job:
repo:my-org/my-repo:environment:prod - Pull request:
repo:my-org/my-repo:pull_request
The
environmentform takes precedence: if a job targets an environment,subbecomes theenvironment:variant and drops theref:segment. This matters because the most common trust-policy bug is pinningsubto a branch ref on a job that actually runs under an environment, which then never matches.
You can request a token manually to inspect exactly what GitHub will send:
- name: Print the OIDC claims for this job
run: |
TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, aud, repository, ref, environment, job_workflow_ref}'
Keep that snippet; the Verify and Audit sections use it.
2. AWS: IAM OIDC provider and a scoped role
Register GitHub as an OIDC identity provider once per AWS account. Modern IAM verifies GitHub’s certificate chain against a trusted CA store, so the thumbprint is no longer required (if you pass --thumbprint-list, IAM ignores it):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
Now create a role whose trust policy validates the GitHub JWT. The aud uses StringEquals; the sub uses StringLike so you can scope to a repo and branch:
{
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
}
Attach a least-privilege permissions policy (not AdministratorAccess) and use it from the workflow:
permissions:
id-token: write # required to mint the OIDC token
contents: read
jobs:
deploy-aws:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy
aws-region: us-east-1
- run: aws sts get-caller-identity
Always pin
aud. Without theStringEqualsaudience condition, a token minted for a different relying party could satisfy asub-only policy. The audience is your confused-deputy guard.
3. Azure: workload identity federation on a user-assigned identity
Azure federates through a federated identity credential (FIC) attached to either an app registration or a user-assigned managed identity. Prefer a user-assigned managed identity: it has no client secret to leak and is RBAC-assignable like any other principal.
az identity create \
--name gha-deploy \
--resource-group rg-platform \
--location eastus
# Capture the values the workflow needs
CLIENT_ID=$(az identity show -n gha-deploy -g rg-platform --query clientId -o tsv)
PRINCIPAL_ID=$(az identity show -n gha-deploy -g rg-platform --query principalId -o tsv)
Add the federated credential. The subject must match GitHub’s sub claim byte-for-byte (case-sensitive), and the audience is the Azure-specific value api://AzureADTokenExchange:
az identity federated-credential create \
--name gha-main-prod \
--identity-name gha-deploy \
--resource-group rg-platform \
--issuer "https://token.actions.githubusercontent.com" \
--subject "repo:my-org/my-repo:environment:prod" \
--audiences "api://AzureADTokenExchange"
Grant the identity scoped RBAC, then log in from the workflow. Note there is no client secret:
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Contributor" \
--scope "/subscriptions/<sub-id>/resourceGroups/rg-app"
deploy-azure:
runs-on: ubuntu-latest
environment: prod
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az account show
A standard FIC matches exactly one subject string. For reusable workflows or many branches, use flexible federated identity credentials, which support a claimsMatchingExpression with wildcards instead of an exact subject:
az identity federated-credential create \
--name gha-flex-branches \
--identity-name gha-deploy \
--resource-group rg-platform \
--issuer "https://token.actions.githubusercontent.com" \
--claims-matching-expression-value "claims['sub'] matches 'repo:my-org/my-repo:ref:refs/heads/release/.*'" \
--claims-matching-expression-version 1 \
--audiences "api://AzureADTokenExchange"
4. GCP: Workload Identity Federation and attribute mapping
GCP uses a workload identity pool with an OIDC provider. The critical control is the --attribute-condition: a CEL expression that rejects tokens before they can map to any identity. Omitting it is how people accidentally let every repo on GitHub assume their service account.
# Pool
gcloud iam workload-identity-pools create github-pool \
--location="global" \
--display-name="GitHub Actions"
# Provider, scoped to one org via attribute-condition
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location="global" \
--workload-identity-pool="github-pool" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository_owner == 'my-org'"
Bind a service account to the federated principal using a principalSet:// member keyed on a mapped attribute. Here we grant only my-org/my-repo:
PROJECT_NUMBER=$(gcloud projects describe my-project --format='value(projectNumber)')
gcloud iam service-accounts add-iam-policy-binding \
gha-deploy@my-project.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"
In the workflow, reference the full provider resource path:
deploy-gcp:
runs-on: ubuntu-latest
steps:
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: gha-deploy@my-project.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth list
GCP is one of the few providers that can condition directly on
job_workflow_ref, because attribute mapping reads any claim. AWS and Azure only seesubandaudunless you customize thesubclaim (next section).
5. Locking trust to branches, tags, environments, and callers
Loose sub patterns are the difference between a control and a rubber stamp. Tighten by intent:
| Intent | sub pattern to require |
|---|---|
Only main |
repo:my-org/my-repo:ref:refs/heads/main |
| Only release tags | repo:my-org/my-repo:ref:refs/tags/v* |
Only the prod environment |
repo:my-org/my-repo:environment:prod |
| Any branch (avoid) | repo:my-org/my-repo:* |
Never ship repo:my-org/my-repo:* to a production role: it trusts every PR and every branch, including attacker-pushed branches on a compromised fork-merge.
Pinning the reusable-workflow caller. When a job runs inside a reusable workflow, GitHub exposes job_workflow_ref as a top-level claim. GCP can condition on it directly:
--attribute-condition="assertion.repository_owner == 'my-org' && assertion.job_workflow_ref == 'my-org/.github/.github/workflows/deploy.yml@refs/heads/main'"
AWS and Azure cannot see job_workflow_ref by default. To gate them on the central workflow, customize the sub claim at the org or repo level so it embeds job_workflow_ref:
gh api -X PUT /repos/my-org/my-repo/actions/oidc/customization/sub \
-f use_default=false \
-f include_claim_keys[]='repository' \
-f include_claim_keys[]='job_workflow_ref'
After that, sub becomes repository:my-org/my-repo:job_workflow_ref:my-org/.github/.github/workflows/deploy.yml@refs/heads/main, which you can match with StringLike in AWS or an exact FIC subject in Azure. Changing the sub template is a breaking change: update every trust policy in the same rollout.
6. Per-environment jobs with concurrency and reviewers
Federation pairs naturally with GitHub Environments. Put required reviewers and branch protection on the prod environment, and the OIDC token only carries environment:prod after a human approves the deployment, so the cloud-side sub condition cannot even be satisfied without that approval. The control is enforced twice: once in GitHub, once in the trust policy.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # never cancel an in-flight prod deploy
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment:
name: prod
url: https://app.example.com
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-prod
aws-region: us-east-1
- run: ./scripts/deploy.sh
Set cancel-in-progress: false for production: cancelling a half-applied deploy is worse than serializing. For ephemeral preview environments, the opposite (true) is usually right.
Going deeper
You have the wiring. This section is the model an experienced engineer needs to reason about failures, sharpen the trust boundary, and answer the hard review questions.
The exchange is different on each cloud — and there are two clocks, not one
“OIDC login” is a friendly name for three genuinely different token exchanges, all built on GitHub’s one JWT:
| AWS | Azure (Entra ID) | GCP | |
|---|---|---|---|
| Trust object | IAM OIDC provider + role trust policy | Federated identity credential on an identity/app | Workload identity pool + OIDC provider |
| API that swaps the token | sts:AssumeRoleWithWebIdentity |
OAuth2 client_assertion (jwt-bearer) grant to the token endpoint |
sts.googleapis.com token exchange (RFC 8693), then optional SA impersonation |
Audience (aud) value |
sts.amazonaws.com (default; configurable) |
api://AzureADTokenExchange |
the full provider resource path (or a custom --allowed-audiences) |
| What it matches on | sub, aud (any claim only if you customize sub) |
subject == sub, or a claimsMatchingExpression |
any mapped claim via attribute-condition (CEL) |
| Result | temporary IAM creds (AccessKeyId/SecretAccessKey/SessionToken) |
an Entra access token for the identity | a federated token, usually exchanged again for SA creds |
Two independent clocks run here, and conflating them causes real confusion. The GitHub OIDC token lives only minutes — it exists to be exchanged once and thrown away. The credentials you get back carry their own lifetime: AWS defaults to a 1-hour STS session (role-duration-seconds, up to the role’s max), Entra and GCP issue their own short-lived tokens. A job that runs longer than the returned credential’s lifetime, not the OIDC token’s, is what silently fails midway. For long deploys, raise the session duration on the assumed role rather than re-minting.
Verifying the signature: JWKS, kid, and key rotation
The cloud never trusts the JWT because it “looks right” — it verifies the RS256 signature against GitHub’s public keys. It reads the token header’s kid (key ID), fetches the matching public key from GitHub’s JWKS URL (discovered via /.well-known/openid-configuration at the issuer), and checks the signature, iss, aud, and expiry. GitHub rotates these signing keys periodically; because the provider re-fetches the JWKS (and caches it briefly), rotation is transparent — which is exactly why AWS dropped the old thumbprint requirement. If you ever pinned a thumbprint by hand and hard-coded it, a key rotation is what would have broken you. Now IAM validates GitHub’s TLS chain against a trusted CA store instead.
The confused-deputy hole, precisely
Why does a sub-only policy leak? Because the same GitHub token can be minted with a caller-chosen audience (...&audience=whatever). If your trust policy checks only sub, then any relying party that can induce your workflow to mint a token for their audience could, in a chained attack, present it to your role. Pinning aud with StringEquals (AWS) / fixing it to api://AzureADTokenExchange (Azure) / constraining --allowed-audiences (GCP) closes it: the token is now bound to the intended recipient. aud is the who is this token for claim; never leave it unchecked.
GCP subtleties: google.subject length and direct resource access
Two GCP-specific traps. First, google.subject (mapped from assertion.sub) has a 127-character limit. GitHub’s environment-form sub for a long org/repo/environment can approach or exceed that, and the mapping fails in a confusing way — map and condition on attribute.repository / a shorter attribute rather than leaning on the full sub when names are long. Second, modern GCP supports direct resource access: instead of principalSet://... impersonating a service account (roles/iam.workloadIdentityUser), you can grant IAM roles straight to the federated principal:///principalSet:// member on the resource, removing the service account entirely. Fewer moving parts, one less thing to over-permission — prefer it where the target service supports it.
Fork PRs get a read-only token — by design
A pull_request event from a fork runs with a read-only GITHUB_TOKEN and, critically, cannot obtain an id-token even if the workflow requests id-token: write. That is a deliberate safety boundary: it means an outside contributor’s PR cannot mint a token that assumes your deploy role. Do not try to “fix” it by running deploy steps on pull_request_target against fork code — that reintroduces exactly the trust problem OIDC is meant to remove. Deploy from push/workflow_run/environment-gated jobs on trusted refs, never from untrusted fork PR code.
GitHub Enterprise Server and the custom issuer
On GitHub Enterprise Server (or GHE Cloud with a unique token URL), the issuer is not token.actions.githubusercontent.com — it is your enterprise’s issuer (e.g. https://<host>/_services/token). Every trust object (--url/--issuer/issuer-uri) must use that exact issuer, and the cloud must be able to reach its JWKS endpoint. Enterprises can also enable an include_enterprise_slug sub customization; if they do, every sub gains an enterprise prefix and every downstream trust policy must be updated in lockstep — the same breaking-change discipline as any sub template change.
Session identity for audit: RoleSessionName and session tags
The credentials you assume aren’t anonymous. configure-aws-credentials sets a RoleSessionName (visible in CloudTrail as assumed-role/gha-deploy/<session>), so every API call is attributable back to the run. You can go further and map GitHub claims into session tags and then write permissions that reference aws:PrincipalTag/..., letting a single role behave differently per repo or environment without a policy per pipeline. The audit trail — which repo, which ref, which run assumed this role and did what — is a first-class benefit of federation that stored keys never gave you.
Verify
Run these before you trust the wiring in anger.
1. Confirm the issued claims match your policy. Add the token-dump step from Section 1 to a throwaway branch and read sub, aud, and environment from the log. They must equal what your trust policy expects, character for character.
2. AWS round trip:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-deploy
aws-region: us-east-1
- run: aws sts get-caller-identity # Arn shows assumed-role/gha-deploy/<run-id>
3. Negative test (the part everyone skips). Push the same workflow on a branch that should not match (e.g. a ref:refs/heads/feature/* push against a main-only role). AWS must return Not authorized to perform sts:AssumeRoleWithWebIdentity, Azure AADSTS700213/AADSTS70021, and GCP a permission-denied on the provider. A trust policy you have never watched fail closed is not yet a control.
4. Inspect the resolved identities:
az identity federated-credential list --identity-name gha-deploy -g rg-platform -o table
gcloud iam workload-identity-pools providers describe github-provider \
--location=global --workload-identity-pool=github-pool \
--format='value(attributeCondition)'
Enterprise scenario
A platform team standardized all deployments behind one reusable workflow in their my-org/.github repo and federated it to ~400 product repos across AWS and GCP. They scoped the AWS roles with StringLike on repo:my-org/*:ref:refs/heads/main so any repo’s main could deploy. Convenient, until a routine pen test flagged it: any engineer with push to any repo’s main (including low-trust internal tools repos) could assume the shared deploy role and reach production AWS accounts. The wildcard repo segment had collapsed 400 trust boundaries into one.
The constraint: they could not enumerate 400 repos in every trust policy, and could not abandon the single reusable workflow that gave them governance. The fix was to stop trusting the caller repo and start trusting the central workflow file. They customized the sub claim org-wide to embed job_workflow_ref, then rewrote the AWS trust policy to pin the workflow, not the repo:
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repository:my-org/*:job_workflow_ref:my-org/.github/.github/workflows/deploy.yml@refs/heads/main"
}
Now only the audited, branch-protected deploy.yml on main could assume the role, regardless of which product repo invoked it. A fork or a hand-rolled workflow in any repo no longer matched, because its job_workflow_ref differed. On GCP they expressed the same rule natively in the --attribute-condition with assertion.job_workflow_ref == '...'. One claim-customization change, applied in lockstep across both clouds’ trust policies, restored the 400 boundaries down to a single trusted, reviewed entry point, with zero stored credentials anywhere.
Migration playbook: rotating out stored keys without breaking pipelines
You cannot flip 400 pipelines at once. Run keys and OIDC in parallel, then starve the keys.
- Stand up federation alongside existing keys. Create the IAM provider/role, Azure FIC, and GCP pool/provider while the old
AWS_ACCESS_KEY_IDsecret still works. Nothing in the running pipeline changes yet. - Add
id-token: writeand switch the login step in a canary repo to OIDC. Leave the secrets in place as a rollback path. - Watch for
submismatches. The dominant failure is environment-vs-refsubdrift; fix the trust condition, not the workflow. - Roll the fleet repo by repo (or via your reusable workflow, which flips everyone at once). Track adoption with a quick audit:
# Find repos still carrying a long-lived AWS key secret
gh api graphql -f query='
query($org:String!){ organization(login:$org){
repositories(first:100){ nodes{ name } } } }' -F org=my-org
# then per repo:
gh secret list --repo my-org/$REPO | grep -i AWS_ACCESS_KEY_ID || echo "clean: $REPO"
- Disable, then delete the keys. In AWS, set the access key to
Inactivefirst (instant, reversible); only after a clean deploy cycle runaws iam delete-access-key. DeleteAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYfrom repo and org secrets. For Azure, remove any app-registration client secrets; for GCP, delete the exported service-account JSON keys withgcloud iam service-accounts keys delete. - Add a guardrail. A scheduled job (or org ruleset) that fails when a
*_ACCESS_KEY*/*_SECRET*secret reappears keeps the regression from sneaking back.
Practice challenges
Work these in order — they climb from “print a token” to “prove the trust fails closed across three clouds”. Treat every <org>, account ID, and resource name as a placeholder; nothing here should be run against a real production account without review. Try each before opening the solution.
Challenge 1 — See your own claims (beginner)
Add a single step to a workflow that prints the decoded sub, aud, and ref of the OIDC token GitHub would issue for that job. What permission must the job have?
<details> <summary>Solution</summary>
jobs:
claims:
runs-on: ubuntu-latest
permissions:
id-token: write # without this, the two ACTIONS_ID_TOKEN_* vars are unset
steps:
- name: Print OIDC claims
run: |
TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, aud, ref}'
Why: id-token: write is what lets the runner reach the token endpoint; the middle segment of a JWT is base64-encoded JSON, so decoding it shows the exact claims your trust policy will have to match.
</details>
Challenge 2 — A main-only AWS trust policy (beginner)
Write the IAM role trust policy that allows sts:AssumeRoleWithWebIdentity only for pushes to main of my-org/my-repo, and pins the audience. Which operator goes on sub, which on aud, and why?
<details> <summary>Solution</summary>
{
"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" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main" }
}
}]
}
Why: aud is a single fixed value, so StringEquals (an exact match) is the tight choice; sub uses StringLike so you can introduce wildcards deliberately later — but here it’s an exact string, and pinning aud is what closes the confused-deputy hole.
</details>
Challenge 3 — Environment sub, not ref sub (intermediate)
You created an Azure federated credential with --subject "repo:my-org/my-repo:ref:refs/heads/main" but the job sets environment: prod, and login fails with AADSTS700213. What is the correct subject, and what general rule does this illustrate?
<details> <summary>Solution</summary>
az identity federated-credential create \
--name gha-prod \
--identity-name gha-deploy \
--resource-group rg-platform \
--issuer "https://token.actions.githubusercontent.com" \
--subject "repo:my-org/my-repo:environment:prod" \
--audiences "api://AzureADTokenExchange"
Why: when a job targets an environment, GitHub sets sub to the environment: form and drops the ref: segment, so a ref-based subject can never match — always confirm the actual sub (Challenge 1) before writing the trust object.
</details>
Challenge 4 — GCP condition + binding for exactly one repo (intermediate)
Write the provider --attribute-condition that admits only my-org and the principalSet:// binding that grants only my-org/my-repo the right to impersonate a service account.
<details> <summary>Solution</summary>
# provider: reject anything not owned by my-org, before it can map to an identity
--attribute-condition="assertion.repository_owner == 'my-org'"
# binding: only my-org/my-repo may impersonate the SA
gcloud iam service-accounts add-iam-policy-binding \
gha-deploy@my-project.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"
Why: the two controls stack — the condition stops non-my-org tokens from mapping to any identity at the pool boundary, and the principalSet:// on attribute.repository/my-org/my-repo narrows which mapped principals can actually assume the service account. Omitting the condition is how people accidentally trust the entire internet’s GitHub repos.
</details>
Challenge 5 — Pin the reusable-workflow caller on all three clouds (advanced)
A central my-org/.github/.github/workflows/deploy.yml@refs/heads/main is the only workflow allowed to deploy. Express “trust this workflow file, regardless of which repo calls it” on GCP (native), and on AWS/Azure (which need help).
<details> <summary>Solution</summary>
# GCP — condition directly on the top-level claim
--attribute-condition="assertion.repository_owner == 'my-org' && assertion.job_workflow_ref == 'my-org/.github/.github/workflows/deploy.yml@refs/heads/main'"
# AWS/Azure can't see job_workflow_ref by default — first embed it in sub:
gh api -X PUT /repos/my-org/my-repo/actions/oidc/customization/sub \
-f use_default=false \
-f include_claim_keys[]='repository' \
-f include_claim_keys[]='job_workflow_ref'
// AWS trust policy, after the sub customization:
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repository:my-org/*:job_workflow_ref:my-org/.github/.github/workflows/deploy.yml@refs/heads/main"
}
Why: job_workflow_ref identifies the called workflow file, not the caller repo, so pinning it collapses “any of N repos” down to “one audited, branch-protected workflow” — GCP reads the claim natively; AWS/Azure only gain access to it once you customize the sub template (a breaking change to roll out everywhere at once).
</details>
Challenge 6 — Prove it fails closed (advanced)
Design the negative test that demonstrates each cloud rejects a non-matching ref, and state the specific error each returns. Why is a passing positive test insufficient on its own?
<details> <summary>Solution</summary>
Push the deploy workflow on a branch that must not match (e.g. feature/x against a main-only trust), and assert each cloud denies:
AWS → Not authorized to perform sts:AssumeRoleWithWebIdentity
Azure → AADSTS700213 (no matching federated identity) / AADSTS70021
GCP → PERMISSION_DENIED on the provider / unable to impersonate
# GCP: confirm the guard is actually present (non-empty)
gcloud iam workload-identity-pools providers describe github-provider \
--location=global --workload-identity-pool=github-pool \
--format='value(attributeCondition)'
Why: a positive test only proves the allowed path works; it says nothing about whether disallowed paths are also (wrongly) allowed. A wildcard or missing attribute-condition will still pass the positive test while trusting everything — only the negative test proves the boundary exists.
</details>
Common beginner mistakes
These are wrong mental models — distinct from the symptom-level errors the Verify section catches.
“OIDC means no configuration — it just works.” OIDC removes the secret, not the trust. You still declare, on each cloud, exactly which token you accept. The work moves from “store and rotate a key” to “write a tight trust policy,” which is strictly better but not zero. A federation with a wide-open sub is less safe than a scoped key.
“The sub claim always contains the branch.” It doesn’t. An environment-gated job’s sub is ...:environment:prod, with no ref: segment. Pinning a ref pattern on an environment job is the single most common reason “it worked in the demo, fails in prod.” Always dump the real sub first.
“I’ll match on sub and skip aud.” aud is the confused-deputy guard. A sub-only policy can be satisfied by a token minted for a different audience. Pin aud with StringEquals (AWS), the fixed api://AzureADTokenExchange (Azure), or --allowed-audiences (GCP) — every time.
“repo:my-org/my-repo:* is fine — it’s still my repo.” That wildcard trusts every ref and event of the repo: every PR, every feature branch, every fork-merge an attacker can push to. A production role must pin a specific branch, tag pattern, or environment — never a bare :*.
“The OIDC token is a secret I should save to reuse.” It is minted fresh per job and expires in minutes. There is nothing to store, and storing it would defeat the entire point. If you find yourself copying a token into a secret, stop — you’ve recreated the anti-pattern.
“id-token: write on the whole workflow is convenient.” Grant it per-job, only on jobs that authenticate. A blanket workflow-level permission hands the token-minting capability to steps (including third-party actions) that have no business with it. Least privilege applies to permissions: too.
“A fork’s PR can deploy through OIDC.” Fork PRs run with a read-only token and cannot get an id-token, by design. Do not reach for pull_request_target to “enable” it — that runs untrusted fork code with your permissions and reopens the exact hole OIDC closes. Deploy only from trusted refs.
“AWS still needs the GitHub thumbprint, and it’ll break on rotation.” Not anymore — modern IAM validates GitHub’s certificate chain against a CA store and ignores a passed --thumbprint-list. Hard-coding a thumbprint is legacy advice that would, ironically, be the thing that breaks on GitHub’s key rotation.
Glossary
- OIDC (OpenID Connect) — an identity layer on top of OAuth 2.0. Here, the mechanism by which GitHub asserts who a job is and a cloud verifies it, with no shared secret.
- JWT (JSON Web Token) — the signed token GitHub mints: three base64url parts (header, payload of claims, signature). Decode the payload to read the claims.
- Claim — one field inside the JWT payload (
sub,aud,ref,environment, …). Trust policies match on claims. iss(issuer) — who minted the token. For GitHub.com it is fixed:https://token.actions.githubusercontent.com. GHES uses a different, per-enterprise issuer.aud(audience) — who the token is for. Pinning it is the confused-deputy guard. AWS defaultsts.amazonaws.com; Azureapi://AzureADTokenExchange; GCP the provider path.sub(subject) — the primary scoping handle; a structured string likerepo:my-org/my-repo:ref:refs/heads/mainor...:environment:prod. Its shape changes with context.job_workflow_ref— the called reusable workflow file + ref, e.g.my-org/.github/.github/workflows/deploy.yml@refs/heads/main. Lets you trust a workflow rather than a caller repo.- JWKS (JSON Web Key Set) — GitHub’s published public keys, discovered via
/.well-known/openid-configurationat the issuer. The cloud uses them to verify the JWT signature. kid(key ID) — the header field naming which JWKS key signed this token; enables transparent key rotation.- Federated identity — trusting an external identity provider (GitHub) to authenticate principals, instead of issuing your own long-lived credentials.
- STS (Security Token Service) — AWS’s service that swaps a verified web-identity token for temporary credentials via
sts:AssumeRoleWithWebIdentity. sts:AssumeRoleWithWebIdentity— the AWS action that trades a JWT for temporary role credentials, gated by the role’s trust policy.- IAM OIDC provider — the AWS object registering GitHub as a trusted issuer (one per account). Prerequisite for web-identity roles.
- Trust policy — the JSON on an IAM role that says who may assume it (here: which
sub/audfrom GitHub). Distinct from the permissions policy (what it can do). - Confused deputy — an attack where a trusted component is tricked into using its authority for an attacker. Pinning
audis the guard. - Federated identity credential (FIC) — the Azure object attaching an external
issuer/subject/audiencetrust to a managed identity or app registration. - User-assigned managed identity — an Azure principal with no client secret, RBAC-assignable — the preferred federation target.
- Flexible FIC /
claimsMatchingExpression— an Azure FIC variant matchingsubwith a wildcard expression instead of one exact subject (for many branches/callers). - Workload Identity Federation (WIF) — GCP’s mechanism to let external identities (like GitHub) access GCP without a service-account key.
- Workload identity pool / provider — the GCP objects that define an external identity source (pool) and how its tokens are validated and mapped (provider).
--attribute-mapping— GCP CEL that copies token claims intogoogle.subjectandattribute.*values usable in bindings.--attribute-condition— GCP CEL that rejects tokens at the pool boundary before they map to any identity. Must be non-empty for a real control.principalSet://— a GCP IAM member referencing a set of federated principals by a mapped attribute (e.g. one repository); used to bindroles/iam.workloadIdentityUser.- Least privilege — granting only the permissions a task needs. Applies to the assumed role/identity and to the workflow’s
permissions:. - Short-lived / temporary credentials — the auto-expiring credentials (≈1 h) returned by the exchange; the point of the whole design, versus standing keys.
- Thumbprint — a legacy fingerprint of GitHub’s TLS certificate once required by AWS; no longer needed and ignored by modern IAM.
StringEquals/StringLike— IAM condition operators for exact vs wildcard matching; used onaudandsubrespectively.- Sub claim customization — a per-repo/org setting (
.../actions/oidc/customization/sub) that reshapessubto embed extra claims likejob_workflow_ref. A breaking change to roll out atomically.