DevOps Azure

Keyless GitHub Actions Deployments with OIDC to AWS, Azure, and GCP

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:

The environment form takes precedence: if a job targets an environment, sub becomes the environment: variant and drops the ref: segment. This matters because the most common trust-policy bug is pinning sub to 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 the StringEquals audience condition, a token minted for a different relying party could satisfy a sub-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 see sub and aud unless you customize the sub claim (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.

  1. Stand up federation alongside existing keys. Create the IAM provider/role, Azure FIC, and GCP pool/provider while the old AWS_ACCESS_KEY_ID secret still works. Nothing in the running pipeline changes yet.
  2. Add id-token: write and switch the login step in a canary repo to OIDC. Leave the secrets in place as a rollback path.
  3. Watch for sub mismatches. The dominant failure is environment-vs-ref sub drift; fix the trust condition, not the workflow.
  4. 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"
  1. Disable, then delete the keys. In AWS, set the access key to Inactive first (instant, reversible); only after a clean deploy cycle run aws iam delete-access-key. Delete AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from repo and org secrets. For Azure, remove any app-registration client secrets; for GCP, delete the exported service-account JSON keys with gcloud iam service-accounts keys delete.
  2. Add a guardrail. A scheduled job (or org ruleset) that fails when a *_ACCESS_KEY* / *_SECRET* secret reappears keeps the regression from sneaking back.

Checklist

github-actionsoidcci-cdcloud-securityfederated-identity

Comments

Keep Reading