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