Security Azure

Secretless CI/CD: Workload Identity Federation for GitHub Actions and AKS

The single most common credential in a breach post-mortem is a static one that should never have existed: a service principal client secret pasted into a CI variable, a personal access token (PAT) in a repository secret, an AWS access key in a .env, a Google service-account JSON key in a bucket. None were rotated, all granted far more than the job needed, and every one was usable from anywhere on the internet by anyone who read the value. Workload identity federation deletes the stored secret outright. Instead of the pipeline proving “I know the secret,” it proves “I am the workload you said you would trust” — presenting a short-lived OIDC token that an identity provider (GitHub’s token service, GitLab’s, or a Kubernetes cluster’s OIDC issuer) mints for that specific job, and that the cloud accepts only because you registered the issuer, subject and audience in advance.

This article is the practitioner’s full treatment of that pattern across the three major clouds. The trust mechanism is identical everywhere — an external identity provider (IdP) signs a JWT, the cloud’s security token service (STS) validates the signature against the IdP’s published JWKS, checks the claims against a trust you declared, and returns a short-lived credential — but the names differ: Azure calls it a federated identity credential (FIC) on an app registration or user-assigned managed identity; AWS, an IAM OIDC identity provider plus a role whose trust policy gates sub/aud via AssumeRoleWithWebIdentity; GCP, a Workload Identity Federation pool and provider with attribute mapping and conditions. Get the subject and audience exactly right and the trust is surgical — usable only from one branch of one repository, one environment, one service account. Get them subtly wrong — an over-broad subject, a wildcard *, the wrong audience, a forgotten aud check — and you have built a confused-deputy hole any fork or any other repo on the same IdP can drive a truck through.

By the end you will be able to take GitHub Actions, GitLab CI, or in-cluster Kubernetes workloads from secret-based to fully federated auth against all three clouds; write the precise claim conditions that scope each trust to exactly the right workload; recognise the misconfigurations that quietly re-open the secret you thought you removed; and explain why a stolen federated token is worth minutes from one place instead of years from anywhere.

What problem this solves

A traditional pipeline authenticates with a bearer credential — ARM_CLIENT_SECRET, AWS_SECRET_ACCESS_KEY, a service-account JSON key, or a GH_PAT. The credential is symmetric and long-lived: whoever possesses the bytes can impersonate the principal, from any network, until it expires — and the default expiry is measured in years, or never. It sits in a secrets store you hope is locked down, gets copied into forks and laptops, leaks through a printenv left in a debug step, and survives in the reflog of whoever once committed it. Rotation is a chore everyone postpones, so the same value is live for the whole life of the project. When it leaks, the blast radius is the full permission set it carries — and because pipeline credentials are usually over-scoped (“Contributor at the subscription,” “PowerUserAccess,” “Editor on the project”), that radius is the whole cloud account.

What breaks without federation is not the happy path — secrets work fine until they leak. What breaks is containment. There is no way to bound a stolen static key to “only the CI job that legitimately uses it.” The key has no notion of where it runs; it is just a string. So the same value that deploys from main also works from an attacker’s laptop, from a malicious pull request, and from a compromised dependency that exfiltrated your .env. You compensate with detection and painful rotation drills, but the fundamental weakness — a portable, durable, over-scoped credential — remains.

Who hits this: every team running CI/CD against a cloud, every Kubernetes workload that calls cloud APIs, every cross-cloud automation carrying one cloud’s static key to reach another. It bites hardest where the credential is most over-scoped and least rotated: the “deploy everything” service principal, the org-wide PAT, the JSON key that has quietly worked for three years. Federation does not make your pipeline more convenient — it changes what a leak is worth. The proof your workload presents is bound to the issuer, the repository, the branch or environment, and a tight time window, verified live against the IdP’s signing keys. A token captured outside that context is dead on arrival; one captured inside it expires in minutes.

To frame the whole field before the deep dive, here is the same trust idea under each cloud’s vocabulary, so you can read any one cloud’s docs and know which knob is the subject and which is the audience:

Concept Azure (Entra ID) AWS (IAM) GCP (IAM)
The trust object you register Federated identity credential (FIC) on an app reg / UAMI IAM OIDC identity provider + role trust policy Workload Identity Pool + Pool Provider
Who the cloud trusts (the IdP) issuer URL on the FIC OIDC provider Url + thumbprint/JWKS Provider issuer-uri (OIDC)
Which workload (the lock) subject (exact string) or claimsMatchingExpression sub / custom claim in trust-policy Condition attribute.* mapping + attribute-condition (CEL)
Who the token is for (replay guard) audiences (default api://AzureADTokenExchange) aud claim, checked in trust policy --allowed-audiences on the provider
The exchange API client_assertion JWT-bearer to Entra token endpoint sts:AssumeRoleWithWebIdentity sts.googleapis.com token exchange
What you get back Entra access token for the app/identity Temporary STS credentials for the role Federated access token, optionally SA impersonation
Permissions are granted by Azure RBAC role assignments The IAM role’s permission policies IAM bindings on the pool principal / impersonated SA

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand the actors in a cloud CI/CD setup: an identity (Entra app reg or UAMI; an AWS IAM role; a GCP service account or workload-identity principal), the permissions attached to it (Azure RBAC; AWS IAM policies; GCP IAM bindings), and that today your pipeline carries a stored secret to assume it. Be comfortable running az, aws, and gcloud, reading JSON, and editing a CI YAML. Familiarity with JWTs — header, claims (iss, sub, aud, exp, nbf), and a signature you verify against the issuer’s public keys — is assumed; that is the entire substance of OIDC federation.

This sits at the intersection of the Security/Identity and DevOps/CI-CD tracks. It is the secretless endpoint of the journey that CI/CD Secrets and Credential Management: Secure Your Pipelines starts and that Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation firefights — federation is the structural fix that makes both less necessary. On Azure it builds on Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns for Azure Workloads and Building a Secure OIDC Confidential Client in Entra ID: App Registrations, Secrets, and Workload Identity Federation; the hardening that follows it lives in Locking Down Workload Identities: Conditional Access, Risk Detection, and Going Secretless. On AWS and GCP it builds on AWS Organizations and IAM Foundations: Accounts, OUs and Roles and GCP IAM and Service Accounts: Roles, Bindings and Least Privilege.

Where it fits in the bigger picture: federation is one pillar of a Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars — the identity pillar’s stance that no standing, portable credential should exist. Below is the map of who owns which leg of a federated setup, so you call the right person when one fails:

Layer What lives here Who usually owns it Failure it can cause
CI platform (GitHub/GitLab/cluster) OIDC token minting, claim contents Platform / DevOps Missing id-token permission → no token at all
IdP issuer + JWKS Token signature, issuer URL, key rotation GitHub/GitLab/cloud (managed) Signature/issuer mismatch → exchange rejected
Cloud trust object (FIC / OIDC provider / pool) Which issuer + subject + audience is trusted Cloud / identity admin Wrong/over-broad trust → over-privileged or no match
Cloud identity (app / role / SA) The principal you become Identity admin Exists but unmapped to the trust
Cloud authorization (RBAC / IAM) What the identity may do Cloud / security Auth succeeds, action denied (403) — separate from trust
Time sync (runner clock) nbf/exp validity window Infra (self-hosted runners) Clock skew → “assertion not within valid time range”

Core concepts

Six mental models make every later configuration obvious.

The proof is possession of context, not possession of a secret. A static credential answers “do you know the secret?” — which an attacker answers with a copied string. A federated workload answers “are you running where the trust says you run?” — which only the genuine workload, inside the genuine CI platform, can answer, because only it can ask the IdP to mint a token carrying the right sub. No symmetric secret exists: the IdP signs with a private key it never shares; the cloud verifies with the public key it fetches from the IdP’s JWKS endpoint.

OIDC is the lingua franca; the JWT claims are the contract. The workload requests an ID token (a JWT) from its IdP carrying standard claims — iss (who minted it), sub (which workload), aud (who it’s for), exp/nbf/iat (the time window) — plus provider-specific ones (GitHub adds repository, ref, environment, job_workflow_ref). The cloud verifies the signature against the issuer’s keys, confirms iss is trusted, confirms the token is in its time window, and confirms sub/aud (and any conditions) match a registered trust — only then issuing a credential. The claims are the entire security boundary, which is why the bulk of this article is about getting them exactly right.

The audience is a replay guard, and it is per-cloud. aud answers “who was this token minted for?” — the cloud’s exchange identifier (api://AzureADTokenExchange for Azure; sts.amazonaws.com for AWS; the provider resource URL for GCP). Because each cloud rejects an aud that is not its own, a token minted for Azure cannot be replayed at AWS, and vice versa. Override the audience and you must override it on both sides — request and trust — or the exchange fails; custom audiences are a frequent self-inflicted outage.

The subject is the lock, and exactness is the safety. sub identifies the specific workload, composed from the trigger context — repo:OWNER/REPO:ref:refs/heads/main (branch push), repo:OWNER/REPO:environment:production (environment job), repo:OWNER/REPO:pull_request (PR). Azure FICs match it as an exact string (no wildcards in a standard FIC); AWS uses StringEquals (exact) or StringLike (wildcard); GCP uses CEL over mapped attributes. Exact-match is the safe baseline: repo:acme/platform:ref:refs/heads/main trusts exactly one branch of one repo. The moment you reach for a wildcard (repo:acme/platform:*), you widen the trust to every branch, PR, and tag of that repo — frequently more than you meant.

Federation grants no permissions; authorization is a separate step. Registering a FIC, an OIDC provider, or a WIF pool establishes authentication trust only — “I will believe a token with these claims” — and grants zero access. Access comes from Azure RBAC, AWS IAM policies, or GCP IAM bindings. The separation is a feature (scope authentication and authorization independently) and the source of the common “it federated but I get 403” confusion — the trust matched, but the identity lacks the role.

Tokens are short-lived; trusts are standing — so you constrain the trust, not the token. The tokens that flow are minted per-job and live minutes (the security win), but the trust objects you register (FICs, OIDC providers, WIF providers) don’t expire on their own — they are standing declarations, like firewall rules. The control you add is constraint and review: tight subjects/conditions, audience checks so tokens can’t be replayed cross-cloud, and a recurring audit so a stale or over-broad trust doesn’t rot silently. A credential left trusting repo:acme/* is exactly what quietly becomes the new weak link.

The claims inside the JWT are the entire contract, so know what each one does and which side checks it:

Claim Meaning Checked by the cloud as Mistake it guards against
iss Issuer — who minted the token Must equal the trust’s registered issuer Accepting tokens from an untrusted IdP
sub Subject — which specific workload Matched against the FIC subject / trust condition A different repo/branch/PR authenticating
aud Audience — who the token is for Must equal the cloud’s expected value Cross-cloud / cross-purpose token replay
exp Expiry — token invalid after this time now ≤ exp A long-lived/stale token being reused
nbf Not-before — token invalid until this time now ≥ nbf A pre-minted token used early; clock skew
iat Issued-at — when the token was minted Sanity / freshness (informational; aids skew diagnosis)
repository, ref, environment (GitHub) Trigger context Available for subject composition / conditions Distinguishing branch vs env vs PR trust

The vocabulary in one table

Before the per-cloud sections, pin down every moving part. The glossary repeats these for lookup; this table is the model side by side:

Term One-line definition Where it lives Why it matters
IdP (identity provider) Service that mints signed OIDC tokens for workloads GitHub/GitLab token service; k8s OIDC issuer The root of trust the cloud verifies against
OIDC token (JWT) Short-lived signed assertion of identity + context Issued per job; held in memory Replaces the stored secret entirely
Issuer (iss) URL identifying who minted the token Claim + the trust’s issuer/Url/issuer-uri First thing the cloud matches
Subject (sub) String identifying the specific workload Claim + the trust’s subject/condition The lock that scopes trust to one workload
Audience (aud) Who the token is intended for Claim + the trust’s audience setting Stops cross-cloud token replay
JWKS The IdP’s public signing keys IdP’s .well-known/jwks endpoint How the cloud verifies the signature
FIC (Azure) Federated identity credential on an app/UAMI Entra app registration / managed identity Azure’s trust object
IAM OIDC provider (AWS) Registered external OIDC issuer IAM → Identity providers AWS’s trust anchor for the issuer
Role trust policy (AWS) Who may assume the role + claim conditions The IAM role AWS’s subject/audience gate
WIF pool + provider (GCP) Federation config mapping external identities IAM → Workload Identity Federation GCP’s trust object
STS exchange The API that swaps the OIDC token for a cloud credential Entra token endpoint / sts API The moment of validation
Client assertion The OIDC token sent as proof in the exchange Request body to Entra Azure’s flavour of the exchange

How an OIDC federation exchange actually works

Strip away the per-cloud names and one sequence runs everywhere — walk it once and every later config slots into place.

  1. The job requests a token from its IdP. In GitHub Actions the runner exposes ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN to a job that declared permissions: id-token: write; the login action asks for a token with a specific aud, and GitHub mints and signs a JWT stamped with this job’s claims (repo, ref/environment, run id, a few-minute lifetime).
  2. The job presents the token to the cloud’s STS. As a client_assertion to the Entra token endpoint (Azure), as the argument to sts:AssumeRoleWithWebIdentity against a role ARN (AWS), or exchanged at sts.googleapis.com then optionally used to impersonate a service account (GCP).
  3. The cloud validates the signature. It fetches the IdP’s public keys from the issuer’s JWKS endpoint (cached, refreshed on rotation) and verifies the JWT. A token not signed by the issuer’s current keys is rejected — you hand the cloud a verifiable assertion, never a secret.
  4. The cloud checks issuer, audience, and time window. iss must equal a trusted issuer; aud must match what the trust expects; nbf ≤ now ≤ exp must hold against the cloud’s clock — which is why runner clock skew surfaces as a “credential” error.
  5. The cloud matches the subject and any conditions. Azure compares sub to the FIC’s exact subject (or its claimsMatchingExpression); AWS evaluates the trust policy’s StringEquals/StringLike on :sub/:aud; GCP evaluates the provider’s CEL attribute-condition. No match → “no matching federated identity record” / “not authorized to perform sts:AssumeRoleWithWebIdentity.”
  6. The cloud issues a short-lived credential. An Entra access token (Azure), temporary STS credentials (AWS), or a federated/impersonated token (GCP) — used for the rest of the run, expiring in minutes to an hour.

Each step has a characteristic failure, and knowing which one you’re stuck on is the whole game during setup:

Step What happens Characteristic failure if it goes wrong Where to look
1. Mint token IdP signs a JWT for the job “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL” Missing permissions: id-token: write
2. Present to STS Token sent in the exchange call “client assertion missing/invalid format” Login action misconfigured; empty client-id
3. Verify signature Cloud checks JWKS signature “signature validation failed” / unknown key Wrong issuer; private/self-hosted IdP not exposed
4. Check iss/aud/time Issuer, audience, validity window “audience invalid”; “assertion not within valid time range” Wrong aud; clock skew on self-hosted runner
5. Match subject/conditions Claims vs the registered trust “no matching federated identity record”; “not authorized to AssumeRoleWithWebIdentity” Subject/condition mismatch (most common)
6. Issue credential Short-lived cloud token returned (success) — but later 403 on actions Trust OK; RBAC/IAM/binding missing

Steps 1–5 are authentication (the trust config); step 6’s usefulness is authorization (RBAC/IAM) — a federation that returns a credential but then 403s has a perfect trust and an empty permission set. The exchange itself (step 2) differs per cloud only at the surface:

Cloud Exchange API / call GitHub Action helper Default audience requested Credential returned
Azure client_assertion JWT-bearer to the Entra token endpoint azure/login@v2 api://AzureADTokenExchange Entra access token for the app/UAMI
AWS sts:AssumeRoleWithWebIdentity aws-actions/configure-aws-credentials@v4 sts.amazonaws.com Temporary STS creds (key/secret/session)
GCP Token exchange at sts.googleapis.com (+ optional SA impersonation) google-github-actions/auth@v2 the provider resource URL (or custom) Federated token, optionally an SA token

GitHub Actions to Azure with no secret

Azure’s federation primitive is the federated identity credential (FIC), attached to either an app registration (with a service principal) or a user-assigned managed identity (UAMI). The UAMI route is preferred — it stays in the resource plane, supports a high FIC limit, and avoids an app object — but both work identically. Create the identity and grant least-privilege roles at the narrowest scope.

# Option A: user-assigned managed identity (recommended for CI)
az identity create --resource-group rg-platform-prod --name id-gha-deploy
APP_CLIENT_ID=$(az identity show -g rg-platform-prod -n id-gha-deploy --query clientId -o tsv)
PRINCIPAL_ID=$(az identity show -g rg-platform-prod -n id-gha-deploy --query principalId -o tsv)

TENANT_ID=$(az account show --query tenantId -o tsv)
SUB_ID=$(az account show --query id -o tsv)

# Scope the role to a resource group, never the subscription root
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/rg-platform-prod"

Now add the FIC. The subject must match the token GitHub will send. For a push to main, that subject is repo:OWNER/REPO:ref:refs/heads/main; the audience is the Azure exchange identifier:

az identity federated-credential create \
  --name "gha-main-branch" \
  --identity-name id-gha-deploy \
  --resource-group rg-platform-prod \
  --issuer "https://token.actions.githubusercontent.com" \
  --subject "repo:acme/platform:ref:refs/heads/main" \
  --audiences "api://AzureADTokenExchange"

In the repository, store the three non-secret values as variables — AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (they are not sensitive). The workflow needs id-token: write to mint the OIDC token, and uses azure/login@v2 with no creds block:

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

permissions:
  id-token: write   # required to mint the OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC, no secret)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Prove we are authenticated
        run: az account show -o table

That is the entire change. azure/login@v2 detects the absent client secret, requests an ID token for audience api://AzureADTokenExchange, and performs the JWT-bearer exchange. No AZURE_CREDENTIALS blob, no client secret anywhere.

The Azure exchange knobs, end to end

Every field that participates in an Azure FIC exchange, what it must equal, and what breaks if it’s wrong:

Field Set where Must equal Failure if wrong
issuer FIC https://token.actions.githubusercontent.com (no trailing slash) “no matching federated identity record”
subject FIC The exact sub GitHub sends for the trigger Same — exact-match miss
audiences FIC api://AzureADTokenExchange (default) “AADSTS700212/AADSTS700024 audience” errors
client-id workflow The app/UAMI client id “AADSTS700016 app not found” if blank/wrong
tenant-id workflow Your tenant id Token endpoint resolves wrong tenant
subscription-id workflow Target subscription az defaults to wrong/no subscription
permissions: id-token: write workflow present Runner can’t mint a token at all
RBAC role assignment Azure Scoped to the resources the job touches Login succeeds, actions 403

A reading note that saves an hour: the issuer is token.actions.githubusercontent.com with no trailing slash, and the subject is case-sensitive and exact. The most common Azure federation failure is a subject off by a branch — and the error echoes the subject Entra received, so you paste that exact string into the FIC.

Scoping Azure trust precisely with subject claims

The subject turns a broad trust into a surgical one. GitHub composes it from the trigger context, and because Entra matches it exactly, the format matters precisely. The common shapes:

Trigger Subject string GitHub sends
Push to a branch repo:OWNER/REPO:ref:refs/heads/main
Push to any branch under a prefix repo:OWNER/REPO:ref:refs/heads/release/... (per-branch; no wildcard in standard FIC)
A tag repo:OWNER/REPO:ref:refs/tags/v1.2.3
A GitHub Environment repo:OWNER/REPO:environment:production
A pull request repo:OWNER/REPO:pull_request
A reusable/called workflow repo:OWNER/REPO:job_workflow_ref:OWNER/.github/.../wf.yml@refs/heads/main (via job_workflow_ref claim)

Mapping these to identities is a design decision; the pattern that holds up in production is to split by privilege along the subject:

# Production deploys: gated by a GitHub Environment with reviewers
az identity federated-credential create \
  --name "gha-env-production" \
  --identity-name id-gha-prod-deploy \
  --resource-group rg-platform-prod \
  --issuer "https://token.actions.githubusercontent.com" \
  --subject "repo:acme/platform:environment:production" \
  --audiences "api://AzureADTokenExchange"

When you genuinely must match many subjects under a pattern, Entra supports a flexible FIC using a claimsMatchingExpression instead of a literal subject. Reach for it deliberately — a pattern is a wider trust by definition; exact-match is the safer baseline.

# Flexible FIC: match a claims pattern instead of an exact subject (use sparingly)
az identity federated-credential create \
  --name "gha-release-branches" \
  --identity-name id-gha-deploy \
  --resource-group rg-platform-prod \
  --issuer "https://token.actions.githubusercontent.com" \
  --audiences "api://AzureADTokenExchange" \
  --claims-matching-expression-value "claims['sub'] matches 'repo:acme/platform:ref:refs/heads/release/.*'" \
  --claims-matching-expression-version 1

The trade-offs between scoping strategies, so you choose with eyes open:

Scoping strategy Subject / condition Trust breadth When to use Risk if misused
Single branch ...:ref:refs/heads/main One branch of one repo Most deploy pipelines Low — the safe default
Environment-gated ...:environment:production Jobs in that env (with reviewers) Production with approvals Low; reviewers add a human gate
Tag/release ...:ref:refs/tags/v* (flexible) All matching tags Release-on-tag pipelines Anyone who can push a tag
Pull request ...:pull_request Any PR (incl. forks if allowed) Plan/read-only only High if it can write — fork hole
Branch prefix (flexible) claims['sub'] matches 'repo:acme/platform:ref:refs/heads/release/.*' All branches under prefix Multi-branch release trains Widens to every matching branch
Whole repo (wildcard) repo:acme/platform:* (flexible) Every ref, PR, env of the repo Almost never Effectively repo-wide write

GitHub Actions to AWS with an IAM OIDC role

AWS federation has two objects: an IAM OIDC identity provider (registers GitHub’s issuer as a trusted external IdP) and an IAM role whose trust policy gates who may assume it via sts:AssumeRoleWithWebIdentity. The role’s permission policies — separate — decide what the credentials can do. Create the OIDC provider once per account; GitHub’s issuer and the AWS audience (sts.amazonaws.com) are fixed:

# One IAM OIDC provider per account for GitHub Actions
aws iam create-open-id-connect-provider \
  --url "https://token.actions.githubusercontent.com" \
  --client-id-list "sts.amazonaws.com" \
  --thumbprint-list "ffffffffffffffffffffffffffffffffffffffff"
# Modern IAM validates the GitHub cert chain via its CA; the thumbprint is a
# legacy field still required by the API. Verify the current value from AWS docs.

Now the role and its trust policy — where you pin aud and sub, and both matter: aud (sts.amazonaws.com) stops a token minted for another cloud being replayed here, and sub scopes to the exact repo/branch:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012: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:acme/platform:ref:refs/heads/main"
      }
    }
  }]
}
aws iam create-role \
  --role-name gha-platform-deploy \
  --assume-role-policy-document file://trust-policy.json
# Attach a least-privilege permission policy (not AdministratorAccess)
aws iam attach-role-policy \
  --role-name gha-platform-deploy \
  --policy-arn arn:aws:iam::123456789012:policy/platform-deploy-least-priv

The workflow uses aws-actions/configure-aws-credentials, which performs AssumeRoleWithWebIdentity under the hood — same id-token: write requirement:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-platform-deploy
          aws-region: ap-south-1
          # audience defaults to sts.amazonaws.com — must match the trust policy
      - run: aws sts get-caller-identity

The critical AWS trust-policy choices

The two operators in the trust policy decide whether your trust is surgical or sloppy — one keyword apart:

Operator on :sub Example value What it matches Verdict
StringEquals repo:acme/platform:ref:refs/heads/main Exactly that one ref Safe — prefer this
StringLike repo:acme/platform:ref:refs/heads/* Every branch of the repo Use only when you truly mean all branches
StringLike repo:acme/platform:* Every ref, tag, PR, env of the repo Dangerously broad — repo-wide
StringLike repo:acme/*:* Every repo in the org Almost always a misconfiguration
(:aud omitted) No audience check Hole — cross-cloud token replay possible

Three rules that prevent the classic AWS federation mistakes:

Rule Why What violating it allows
Always pin :aud to sts.amazonaws.com The audience is the replay guard A token minted for another aud/cloud could assume the role
Prefer StringEquals on :sub; use StringLike only with a tight pattern StringLike with * widens silently An attacker’s branch/PR/fork in the same repo (or org) assuming the role
Never repo:owner/* unless every repo is equally trusted One trust spanning all repos Any repo in the org assuming a deploy role

A worked danger: a trust policy conditioning only on StringLike "...:sub": "repo:acme/platform:*" with no aud check is assumable from any trigger in that repo — including a fork pull_request and any pushable tag — and is exposed to token replay. The fix is the exact-match sub plus the aud pin above.

GitHub Actions to GCP with Workload Identity Federation

GCP’s federation uses a Workload Identity Pool (a container for external identities) holding an OIDC Provider (the GitHub issuer, with attribute mapping and a condition). External identities are either granted IAM directly or used to impersonate a service account. The attribute mapping turns token claims into GCP attributes you can condition and bind on.

PROJECT_ID="acme-platform-prod"
PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')

# 1. Create the pool
gcloud iam workload-identity-pools create github-pool \
  --project="$PROJECT_ID" --location="global" \
  --display-name="GitHub Actions pool"

# 2. Create the OIDC provider in the pool, mapping + conditioning claims
gcloud iam workload-identity-pools providers create-oidc github-provider \
  --project="$PROJECT_ID" --location="global" \
  --workload-identity-pool="github-pool" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --allowed-audiences="https://github.com/acme" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --attribute-condition="assertion.repository=='acme/platform' && assertion.ref=='refs/heads/main'"

The attribute condition is GCP’s subject lock — a CEL expression over the token’s claims; the example above admits only acme/platform on refs/heads/main. Bind the pool principal (scoped by the mapped attribute) to a service account, or grant it IAM directly:

# Let the matching external identity impersonate a deploy service account
gcloud iam service-accounts add-iam-policy-binding \
  "gha-deploy@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project="$PROJECT_ID" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/acme/platform"

The workflow uses google-github-actions/auth, pointing at the provider and (optionally) the SA to impersonate:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          project_id: acme-platform-prod
          workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: gha-deploy@acme-platform-prod.iam.gserviceaccount.com
      - run: gcloud auth list

GCP attribute mapping and conditions — the knobs

The mapping and condition are where GCP trust lives — fields and what each does:

Field Purpose Example Mistake to avoid
issuer-uri The trusted IdP https://token.actions.githubusercontent.com Trailing slash / wrong host
allowed-audiences Accepted aud values https://github.com/acme (or the provider URL) Omitting it (provider URL becomes default)
attribute-mapping google.subject The required identity attribute assertion.sub Mapping to something non-unique
attribute-mapping attribute.repository A claim you’ll condition/bind on assertion.repository Forgetting to map a claim you condition on
attribute-condition (CEL) The admission gate assertion.repository=='acme/platform' && assertion.ref=='refs/heads/main' No condition → pool admits any token from the issuer
IAM binding principalSet Who gets which role .../attribute.repository/acme/platform Binding workloadIdentityPools/<pool>/* (whole pool)

The most dangerous GCP-specific mistake has no analogue in the others: a provider with no attribute-condition admits a token from anyone who can get GitHub to mint one for that audience — every public repo on GitHub shares the same issuer. The condition (and a non-default audience) is not optional hardening; it is the boundary. Always pin at least assertion.repository in the condition, and bind IAM on a scoped attribute.repository/<owner>/<repo> principalSet, never the bare pool.

GitLab CI and other IdPs

GitHub is not the only IdP. GitLab CI/CD exposes ID tokens via the id_tokens keyword with an aud you choose; its issuer is your GitLab instance and its subject encodes the project path, ref, and ref type. The cloud config is the same shape — only the issuer and subject format change.

# .gitlab-ci.yml — request an ID token for the cloud, then exchange it
deploy:
  id_tokens:
    AZURE_ID_TOKEN:
      aud: api://AzureADTokenExchange
  script:
    - az login --service-principal -u "$AZURE_CLIENT_ID" -t "$AZURE_TENANT_ID"
        --federated-token "$AZURE_ID_TOKEN"
    - az account show

The issuer/subject shapes differ by IdP — the rest of the trust object is identical:

IdP Issuer (iss) Subject (sub) shape Notes
GitHub Actions https://token.actions.githubusercontent.com repo:OWNER/REPO:ref:refs/heads/main (and env/tag/PR variants) Most common; subject composed from trigger
GitLab.com CI https://gitlab.com project_path:GROUP/PROJECT:ref_type:branch:ref:main aud chosen via id_tokens
GitLab self-managed https://gitlab.example.com as above Issuer must be reachable for JWKS
Kubernetes (AKS/EKS/GKE) The cluster’s OIDC issuer URL system:serviceaccount:NAMESPACE:SERVICEACCOUNT In-cluster workloads, not CI
Terraform Cloud / HCP https://app.terraform.io organization:ORG:project:PROJ:workspace:WS:run_phase:apply Run-phase in the subject
Buildkite, CircleCI, etc. provider-specific provider-specific Same exchange; check the provider’s claims

The discipline is identical: register the issuer, pin the audience, lock the subject to the exact workload, never trust a wildcard you didn’t mean.

Federating in-cluster Kubernetes workloads

The same OIDC primitive secures workloads inside a cluster, not just CI. Each managed Kubernetes service gives the cluster an OIDC issuer; a service account is the workload’s identity; a projected SA token (the OIDC JWT) is mounted into the pod; and the cloud trusts that issuer + the SA subject. No cloud secret is mounted, and the per-pod identity is far tighter than sharing the node’s identity across every pod.

AKS Workload Identity → Azure

AKS gives the cluster an OIDC issuer; a service account is annotated with an Entra client id; a mutating webhook projects the token and sets the env vars the Azure SDKs read.

az aks update -g rg-platform-prod -n aks-platform-prod \
  --enable-oidc-issuer --enable-workload-identity
ISSUER_URL=$(az aks show -g rg-platform-prod -n aks-platform-prod \
  --query oidcIssuerProfile.issuerUrl -o tsv)

az identity create -g rg-platform-prod -n id-orders-api
UAMI_CLIENT_ID=$(az identity show -g rg-platform-prod -n id-orders-api --query clientId -o tsv)

az identity federated-credential create \
  --name "fic-orders-api" --identity-name id-orders-api -g rg-platform-prod \
  --issuer "$ISSUER_URL" \
  --subject "system:serviceaccount:orders:orders-api" \
  --audience "api://AzureADTokenExchange"

The pod template must carry the azure.workload.identity/use: "true" label or the webhook injects nothing:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-api
  namespace: orders
  annotations:
    azure.workload.identity/client-id: "<UAMI_CLIENT_ID>"
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: orders-api, namespace: orders }
spec:
  replicas: 2
  selector: { matchLabels: { app: orders-api } }
  template:
    metadata:
      labels:
        app: orders-api
        azure.workload.identity/use: "true"   # webhook trigger — mandatory
    spec:
      serviceAccountName: orders-api
      containers:
        - name: orders-api
          image: acrkloudvin.azurecr.io/orders-api:1.4.0

EKS → AWS (IRSA and Pod Identity)

AWS offers two routes. IRSA (IAM Roles for Service Accounts) is the OIDC-federation flavour: the cluster’s OIDC provider is registered in IAM and a role’s trust policy conditions on the SA subject. EKS Pod Identity is a newer, association-based approach that avoids per-cluster OIDC provider management.

# IRSA: register the cluster OIDC provider, then condition a role's trust on the SA
eksctl utils associate-iam-oidc-provider --cluster orders-cluster --approve
# Trust policy condition (StringEquals on the OIDC sub):
#   "<oidc-provider>:sub": "system:serviceaccount:orders:orders-api"
#   "<oidc-provider>:aud": "sts.amazonaws.com"

The IRSA vs Pod Identity choice:

Aspect IRSA (OIDC federation) EKS Pod Identity
Mechanism OIDC provider + role trust policy on sub Association API maps SA → role
Per-cluster setup Register OIDC provider per cluster No OIDC provider to manage
Cross-account roles Supported Supported
Trust scoping sub = system:serviceaccount:ns:sa Association names the SA directly
When to use Existing IRSA estates; fine-grained OIDC needs New clusters; simpler operations

GKE → GCP (Workload Identity Federation for GKE)

GKE’s Workload Identity Federation lets a Kubernetes service account act as (or impersonate) a Google service account, with no exported key.

gcloud container clusters update orders-cluster \
  --workload-pool="acme-platform-prod.svc.id.goog"
# Bind the KSA to a GSA (or use direct IAM on the KSA principal)
gcloud iam service-accounts add-iam-policy-binding \
  "orders-api@acme-platform-prod.iam.gserviceaccount.com" \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:acme-platform-prod.svc.id.goog[orders/orders-api]"

The three in-cluster models side by side:

Cloud Feature Issuer Subject Mounted into pod
Azure / AKS Workload Identity Cluster OIDC issuer URL system:serviceaccount:ns:sa Projected token + AZURE_* env via webhook
AWS / EKS IRSA Cluster OIDC issuer URL system:serviceaccount:ns:sa Projected token + AWS_* env via webhook
AWS / EKS Pod Identity (association, not OIDC) SA named in association Credentials via the Pod Identity agent
GCP / GKE WIF for GKE *.svc.id.goog workload pool ns/sa via workload pool Metadata-server-backed tokens

The security wins, precisely stated

Federation’s value is not “no secrets to manage” — it is a different threat model. State the wins concretely so you can defend the migration:

Property Static secret (PAT / client secret / access key / JSON key) Workload identity federation
Credential at rest A long-lived string in a store/var/file None — nothing is stored
Lifetime of usefulness if leaked Until expiry (often years/never) Minutes (the token’s exp)
Usable from where Anywhere on the internet Only from the trusted IdP context (repo/branch/env/SA)
Rotation Manual, perpetually deferred N/A — short-lived tokens self-expire
Blast radius of a leak Full permission set, indefinitely A single job’s window, then gone
Copyable into forks / laptops Yes (and routinely is) No — the token is minted per-job, in-context
Detection dependency Relies on secret scanning to catch leaks Leak of a stale token is near-worthless
Audit story “We rotate quarterly (we hope)” “There is no standing credential to steal”

And the attack paths each property closes:

Attack path How it works against a static secret Why federation closes it
Leaked .env / committed key Attacker uses the string from anywhere No string exists; token is per-job, expired
Compromised dependency exfiltrates env Secret is in the env, gets stolen The federated token is short-lived and context-bound
Fork PR steals secret Secret exposed to fork workflow Default GitHub policy withholds secrets/tokens from fork PRs; scope pull_request to read-only anyway
Insider copies the CI variable Variable is a portable credential Nothing portable to copy
Old credential never rotated Same value live for years No standing credential to age
Cross-cloud replay A key for one cloud is just a key aud pin makes a token cloud-specific

The one caveat: federation moves the trust from “possession of a secret” to “correctness of the claim conditions.” Mis-scope the subject (wildcard the repo, trust pull_request for writes, omit the audience) and you have re-created a portable credential — any workload that satisfies the loose condition can authenticate. Federation is only as tight as its subject and audience, which is why the misconfiguration section is not optional reading.

Architecture at a glance

Picture the flow as three planes that never share a secret. In the CI/identity plane sits the identity provider — GitHub’s token service, GitLab’s, or a Kubernetes cluster’s OIDC issuer — holding a private signing key and publishing the matching public keys at a well-known JWKS endpoint. A job (or a pod) that has declared id-token: write asks this IdP for a token scoped to a chosen audience; the IdP stamps the claims for this specific executioniss (itself), sub (this repo and branch, this environment, or this service account), aud (the cloud it’s destined for), and a few-minute exp/nbf window — signs the JWT, and hands it back. Nothing about this token is reusable elsewhere or later.

The token then crosses into the cloud STS plane — a client_assertion to the Entra token endpoint (Azure), the argument to sts:AssumeRoleWithWebIdentity (AWS), or an exchange at sts.googleapis.com (GCP). Whichever cloud, the STS does the identical verification: fetch the IdP’s public keys from JWKS, verify the signature, confirm iss is a trusted issuer, confirm aud is this cloud’s expected value (the replay guard), confirm the clock is inside [nbf, exp], then match sub (and any conditions) against the trust object — the FIC’s exact subject, the AWS trust policy’s StringEquals on :sub, or the GCP provider’s CEL attribute-condition. Every check is a structural gate; failing any one returns a “no matching record / not authorized” rejection.

Only after all gates pass does the flow reach the cloud resource plane, where the STS issues a short-lived credential — an Entra access token, AWS temporary STS credentials, or a GCP federated/impersonated token. What that credential may touch is decided by a wholly separate authorization layer: Azure RBAC, AWS permission policies, or GCP IAM bindings. The model to carry away: the IdP asserts identity-in-context, the STS verifies it against a standing trust and mints a brief credential, and RBAC/IAM authorizes what it does — three planes, one short-lived token handed forward, no symmetric secret anywhere.

Real-world scenario

Lumio Retail, a mid-size e-commerce platform on a multi-cloud footprint, ran 47 pipelines: Terraform and Bicep deploys to Azure, container builds pushed to AWS ECR, and a data-export job that wrote to a GCS bucket using a Google service-account JSON key. The Azure pipelines carried a single service principal client secret — Contributor at the subscription root — shared across all of them. The AWS pipelines used a long-lived IAM access key with PowerUserAccess. The GCS key was a JSON file added to GitHub secrets three years prior by an engineer who had since left. A routine secret scan during a SOC 2 audit flagged the JSON key as present in an old branch’s history; nobody could say what it could reach or whether it had ever been used from outside CI. That single finding triggered the federation project.

The migration ran cloud by cloud over three weeks. On Azure they replaced the shared secret with per-environment user-assigned managed identities: a prod-deploy UAMI federated against environment:production (the environment gained two required reviewers) and a plan UAMI federated against pull_request holding only Reader; the subscription-root Contributor was deleted and the new identities scoped to their resource groups. On AWS they stood up an IAM OIDC provider and a gha-ecr-push role whose trust policy pinned StringEquals on :aud = sts.amazonaws.com and :sub = repo:lumio/platform:ref:refs/heads/main, with a policy allowing exactly ecr:* on the one repository — PowerUserAccess retired. On GCP they created a workload-identity pool with an attribute-condition of assertion.repository=='lumio/platform', bound the data-export SA’s workloadIdentityUser to the scoped attribute.repository principalSet, and deleted the JSON key.

Two things went wrong, both instructive. First, a self-hosted runner pool in a locked-down subnet failed every Azure deploy with AADSTS700024: Client assertion is not within its valid time range, while GitHub-hosted runners worked — clock skew, because the subnet blocked UDP/123 to public NTP and the nodes had drifted ~6 minutes against a minutes-wide nbf/exp window. They pointed chrony at an internal time source and added a CI guard that fails loud above two seconds of skew. Second, the AWS trust policy was initially written with StringLike "...:sub": "repo:lumio/platform:*" for “convenience”; a reviewer caught that this also trusted every PR and tag and tightened it to the exact-match main subject plus a separate read-only role for plan jobs. The final estate had zero stored cloud credentials, every trust scoped to one repo and (mostly) one branch, and an audit answer that changed from “we rotate quarterly” to “there is nothing to rotate.” Added cloud cost: effectively nil — federation has no per-use charge.

Advantages and disadvantages

Advantages Disadvantages / costs
No secret at rest — nothing to store, rotate, or leak Setup is more conceptual than pasting a secret; teams must learn the model
Stolen token is worth minutes from one context, not years from anywhere Mis-scoped subject/audience silently re-creates a portable credential
Per-workload identity (per repo/branch/env/SA), not one shared key More trust objects to inventory and review (FICs/providers/pools)
Rotation problem disappears (tokens self-expire) Self-hosted runners introduce a clock-sync dependency (nbf/exp)
Audience pin prevents cross-cloud token replay Each cloud uses different names/CLIs — a learning surface per cloud
Authentication (trust) and authorization (RBAC/IAM) cleanly separated The “federated but 403” confusion when RBAC/IAM is forgotten
Works for both CI/CD and in-cluster workloads with one primitive Private/self-managed IdPs must expose a reachable JWKS endpoint
Strong audit story; no standing credential to compromise Some legacy tools/providers still assume a static key (shrinking)

When each side matters: the advantages dominate for any cloud-touching pipeline and any in-cluster workload — there is essentially no production reason to prefer a stored cloud secret today. The disadvantages matter most in transition (learning curve, trust inventory) and in self-hosted-runner setups (clock sync). The single risk to take seriously is mis-scoping: federation done loosely is not safer than a secret, so the subject/audience discipline is the whole job.

Hands-on lab

This lab federates a GitHub Actions workflow to Azure with a UAMI and no secret, then proves the chain and tears it down. It uses only free resources (UAMI, resource group, and role assignment all cost nothing). You need an Azure subscription, az logged in, and a GitHub repo you control.

Step 1 — Create a resource group and a user-assigned managed identity.

LOC=centralindia
az group create -n rg-wif-lab -l "$LOC"
az identity create -g rg-wif-lab -n id-wif-lab
CLIENT_ID=$(az identity show -g rg-wif-lab -n id-wif-lab --query clientId -o tsv)
PRINCIPAL_ID=$(az identity show -g rg-wif-lab -n id-wif-lab --query principalId -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
SUB_ID=$(az account show --query id -o tsv)
echo "CLIENT_ID=$CLIENT_ID  TENANT_ID=$TENANT_ID  SUB_ID=$SUB_ID"

Expected: the three IDs print. Save them; they are the non-secret values the workflow needs.

Step 2 — Grant a least-privilege role at the resource-group scope.

az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/rg-wif-lab"

Expected: a JSON role-assignment object. Reader is enough to prove auth without risking changes.

Step 3 — Register the federated identity credential for your repo’s main branch. Replace OWNER/REPO.

az identity federated-credential create \
  --name "gha-lab-main" --identity-name id-wif-lab -g rg-wif-lab \
  --issuer "https://token.actions.githubusercontent.com" \
  --subject "repo:OWNER/REPO:ref:refs/heads/main" \
  --audiences "api://AzureADTokenExchange"

Expected: a JSON FIC object echoing your issuer/subject/audience.

Step 4 — Store the three non-secret values as repository variables. In the repo: Settings → Secrets and variables → Actions → Variables → New repository variable. Add AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID with the values from Step 1. (Variables, not secrets — they are not sensitive.)

Step 5 — Add the workflow. Commit .github/workflows/wif-lab.yml to main:

name: wif-lab
on:
  workflow_dispatch:
  push:
    branches: [main]
permissions:
  id-token: write
  contents: read
jobs:
  prove:
    runs-on: ubuntu-latest
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - name: Prove federated auth
        run: |
          az account show -o table
          az group show -n rg-wif-lab -o table

Step 6 — Run and read the logs. Trigger via the Actions tab (Run workflow) or by pushing to main. The Azure login step should succeed with no secret, and az account show should print your subscription. This is the whole proof: a job authenticated to Azure with nothing stored.

Step 7 — Validate the trust is exact (negative test). Create a branch wif-test, push the same workflow there, and run it. The login fails with AADSTS70021: No matching federated identity record found — because the FIC trusts only refs/heads/main. The error echoes the subject Entra received (...:ref:refs/heads/wif-test). This proves the subject is a real lock, not a formality.

Step 8 — Confirm no secret exists and review the trust.

az identity federated-credential list --identity-name id-wif-lab -g rg-wif-lab -o table
# A managed identity cannot even have a client secret — there is nothing to leak.

Step 9 — Teardown.

az group delete -n rg-wif-lab --yes --no-wait
# In the repo: delete the workflow file and the three variables.

Expected: the resource group (UAMI, role assignment, FIC) is removed.

The lab’s checklist, so you can confirm each leg landed:

Step What it proves Pass signal
1–2 Identity exists with least-privilege role IDs print; Reader assignment created
3 Trust registered for one branch FIC JSON with correct subject
4–5 Workflow wired with id-token: write, no secret Variables set; workflow committed
6 Federation works end to end Login step green; az account show correct
7 Subject is an exact lock Branch run fails with AADSTS70021
8 No standing credential exists FIC listed; no secret possible on a UAMI
9 Clean teardown RG deletion accepted

Common mistakes & troubleshooting

Federation fails in a small number of characteristic ways, each with a precise tell. This is the playbook — symptom, root cause, how to confirm, and the fix — across all three clouds.

# Symptom Root cause Confirm (exact command / path) Fix
1 Azure: AADSTS70021 No matching federated identity record found Subject mismatch — FIC subject ≠ the sub GitHub sent (wrong branch/env/PR) Read the error: it echoes the received subject; az identity federated-credential list Set the FIC subject to the exact string in the error
2 Azure: AADSTS700024 Client assertion is not within its valid time range Clock skew on a self-hosted runner — nbf/exp window missed chronyc tracking on the runner; compare to a known time Fix NTP (internal time source); fail-fast CI guard on skew
3 Azure: AADSTS700212/AADSTS700024 audience error Wrong audience — request aud ≠ FIC audiences az identity federated-credential show --query audiences; check login action audience Align both to api://AzureADTokenExchange (or your custom value on both sides)
4 Any cloud: “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL” / no token Job lacks permissions: id-token: write Inspect the workflow permissions block Add id-token: write (and contents: read) to the job/workflow
5 AWS: Not authorized to perform sts:AssumeRoleWithWebIdentity Trust policy sub/aud condition doesn’t match the token Decode the token’s sub/aud; compare to the trust policy Condition Correct StringEquals on :sub; pin :aud to sts.amazonaws.com
6 AWS: assume works from unexpected branches/PRs StringLike with * (e.g. repo:org/repo:*) over-broad aws iam get-role --query 'Role.AssumeRolePolicyDocument' Tighten to StringEquals exact subject; split read-only role
7 GCP: unauthorized_client / token rejected Attribute condition fails or audience not allowed gcloud iam workload-identity-pools providers describe ...; check attribute-condition, allowed-audiences Fix the CEL condition; add the correct --allowed-audiences
8 GCP: pool admits unexpected identities No attribute-condition, or IAM bound on the bare pool Describe the provider (empty condition); inspect SA IAM principalSet Add attribute.repository/ref condition; bind a scoped attribute.* principalSet
9 Any cloud: auth succeeds, then 403/denied on actions Trust matched but identity lacks permissions (RBAC/IAM) Azure: az role assignment list --assignee <id>; AWS: role policies; GCP: SA bindings Grant least-privilege role/policy/binding at the right scope
10 Fork PR can authenticate / steal credential pull_request subject trusted for a write identity; or secrets exposed to forks Check which identity pull_request maps to; check repo fork-PR settings Map pull_request to read-only only; never write; rely on GitHub withholding secrets from forks
11 Self-managed GitLab/k8s: “signature validation failed” Cloud can’t reach the issuer’s JWKS, or issuer URL wrong Verify the issuer is internet-reachable; curl <issuer>/.well-known/openid-configuration Expose JWKS publicly (or use a supported reachable issuer); fix the issuer URL
12 Migration “done” but a secret still works Orphaned client secret / access key / JSON key left alongside the FIC Azure: az ad app credential list; AWS: aws iam list-access-keys; GCP: gcloud iam service-accounts keys list Delete every static credential after federation is verified
13 AKS pod: authenticates but env vars missing Pod missing azure.workload.identity/use: "true" label, or SA annotation wrong kubectl get pod -o yaml (labels); kubectl describe sa (annotation) Add the label; fix the SA client-id annotation
14 Trailing-slash / case issues on issuer or subject token.actions.githubusercontent.com/ (slash) or wrong case Compare configured issuer/subject byte-for-byte to the token Use no trailing slash; match case exactly

The entries that bite hardest, expanded:

Subject mismatch (Azure #1 / AWS #5). The number-one first-time failure on every cloud: the trust was registered for refs/heads/main but the job ran on a feature branch — or in a GitHub Environment, so the subject is environment:..., not ref:.... The cloud’s error echoes the exact subject it received; copy that string verbatim into the trust. To see the claims mid-debug, decode the token (setup only, never in production):

- name: (debug) decode the OIDC token's claims
  env:
    ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}
  run: |
    TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r .value)
    echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss,sub,aud}'

Clock skew on self-hosted runners (Azure #2). With federation, a “credential” failure is often not about trust at all. The OIDC token’s nbf/exp window is minutes wide; a drifted runner clock produces an assertion the cloud reads as future-dated or expired. The usual culprit is a locked-down subnet blocking UDP/123 to public NTP. Point the runner at an internal time source and guard against drift:

# chrony pointed at an internal time source on the runner
cat >/etc/chrony/conf.d/internal.conf <<'EOF'
server ntp.corp.internal iburst
makestep 1.0 3
EOF
systemctl restart chrony && chronyc tracking | grep "System time"
- name: Assert clock sane before cloud login
  run: |
    skew=$(chronyc tracking | awk '/System time/{print $4}')
    awk -v s="$skew" 'BEGIN{ if (s+0 > 2) { print "clock skew "s"s"; exit 1 } }'

Orphaned static credentials (#12). A FIC and a client secret can coexist on the same app; an AWS role can be OIDC-assumable and have a live access key; a GCP SA can have WIF bindings and an exported JSON key. The migration isn’t done until every static credential is deleted — otherwise the old attack path is wide open and you’ve added complexity for no gain. (The fork-trust hole, #10, is the other one to internalise: map pull_request only to a read-only identity, never one that can write.) Audit the leftovers explicitly:

# Azure: any password credentials still on the app?  (expect empty)
az ad app credential list --id "$APP_ID" -o table
# AWS: any long-lived access keys still on the role's user/principal?
aws iam list-access-keys --user-name ci-deployer
# GCP: any user-managed keys still on the service account?  (expect only Google-managed)
gcloud iam service-accounts keys list \
  --iam-account "gha-deploy@acme-platform-prod.iam.gserviceaccount.com" \
  --managed-by=user

The safe cut-over sequence per pipeline — never delete the secret before federation is proven:

Step Action Done when Rollback if it fails
1 Inventory the static credential and its exact permissions You know what scope to reproduce n/a
2 Create the federated identity (FIC / role / SA) with least-privilege RBAC/IAM Identity + permissions exist Delete the new identity
3 Register the trust with the exact subject + audience Trust object created Delete the trust object
4 Add id-token: write and the OIDC login to the workflow (keep the secret path too) Workflow updated Revert the workflow commit
5 Run on a non-prod branch/env and confirm login + a real action Green run, action succeeds Stay on the secret path
6 Cut production over; verify with get-caller-identity / az account show Prod runs federated Re-enable the secret path
7 Delete the static credential and remove its variable/secret credential list is empty (only after 6 is stable)
8 Add the new trust to the access-review inventory Trust is tracked n/a

Best practices

Security notes

Removing the stored secret is necessary, not sufficient. The surface that remains is the trust configuration, and it must be governed like any other access control.

A compact view of the controls that harden a federated setup and what each defends:

Control Mechanism Defends against
Tight subject / condition Exact sub / StringEquals / CEL condition Other branches/PRs/repos authenticating
Audience pin aud checked on the trust Cross-cloud / cross-purpose token replay
Least-privilege RBAC/IAM Scoped role/policy/binding A matched trust doing more than its job
Environment + reviewers GitHub Environment gate Unreviewed production deploys
Conditional Access (Azure) Location/risk policy on the SP Stolen token used from an unknown network
Read/write identity split Separate identities per privilege A PR satisfying a write trust
Trust-as-code + review Terraform/Bicep + PR approval Silent, unreviewed trust widening
Inventory + access review Periodic FIC/provider audit Stale/over-broad trusts rotting in place

Because authorization is the real blast radius, scope it concretely — what “least privilege” looks like for a deploy identity on each cloud, versus the over-scoped default to avoid:

Cloud Grant via Least-privilege example Over-scoped anti-pattern
Azure RBAC role assignment Contributor at /resourceGroups/rg-platform-prod Owner/Contributor at the subscription root
AWS IAM policy on the role ecr:* on one repository ARN AdministratorAccess / PowerUserAccess
GCP IAM binding on the principal/SA roles/storage.objectAdmin on one bucket roles/editor at the project
All Read/write split Reader/viewer identity for plan & PR jobs One identity for plan and deploy

Cost & sizing

The headline: workload identity federation has essentially no direct cost on any of the three clouds. There is no per-token, per-exchange, or per-FIC charge — you are not billed for an FIC, an IAM OIDC provider, an AssumeRoleWithWebIdentity call, or a workload-identity pool. The savings are real: no rotation drills, no risk-weighted leak cost, and on Azure no Key Vault round-trips to fetch a stored credential. The only spend that can appear is incidental and pre-existing:

Item Direct federation cost Why it might appear Rough INR / month
FIC / OIDC provider / WIF pool None The trust objects are free ₹0
Token exchange / AssumeRoleWithWebIdentity None No per-call charge for the exchange ₹0
Conditional Access for workload identities (Azure) Licensed Requires Workload Identities Premium add-on per-SP licence (modest, per identity)
NAT Gateway for self-hosted runners Incidental Locked-down runners need egress; not caused by federation ~₹1,500–3,000
Managed identity / IAM role / service account None The identities themselves are free ₹0
App Insights / CloudTrail / Cloud Audit logging Per-GB (already paid) You should log auth events regardless small, shared

Sizing here is not about money but trust object count. Each cloud bounds how many FICs/conditions you attach, so the design question is “one identity per (repo × branch/env)” vs “fewer identities with patterned trust.” Prefer more, tighter identities (the limits are generous) until inventory becomes a burden. A practical estate of a few dozen pipelines lands at dozens of FICs/providers — well within limits — at zero incremental cost. The honest floor for “secretless and safe” is the one-time migration cost, after which federation’s run-rate is nil and the rotation/leak costs you were paying disappear.

Interview & exam questions

1. Explain the OIDC workload-identity-federation trust model in one breath. An IdP (GitHub/GitLab/a k8s cluster) mints a short-lived signed JWT for a specific workload, carrying iss, sub, aud, and a time window. The workload presents it to the cloud’s STS, which verifies the signature against the IdP’s JWKS, confirms issuer/audience/validity, and matches the subject (and any conditions) against a pre-registered trust (FIC / IAM OIDC provider + trust policy / WIF provider). On a match it returns a short-lived credential. No symmetric secret is ever stored or transmitted.

2. Why is a federated token safer than a long-lived secret or access key? The static secret is portable and durable — usable from anywhere, by anyone who reads it, for years. The federated token is bound to a context (this repo/branch/environment/SA) and lives minutes; a copy taken outside that context is rejected, one taken inside it expires almost immediately. The blast radius shrinks from “full permissions, indefinitely” to “one job’s window.”

3. What is the role of the audience (aud) claim, and what breaks if you omit it? aud declares who the token was minted for; the cloud rejects a token whose aud isn’t its own (api://AzureADTokenExchange, sts.amazonaws.com, the GCP provider’s value). It’s a replay guard, stopping a token minted for one cloud/purpose being accepted by another. Omitting the check (common in sloppy AWS trust policies or GCP providers with no allowed-audiences) opens the trust to cross-cloud token replay.

4. How does GitHub compose the subject for a branch push vs an environment deploy vs a pull request? Branch: repo:OWNER/REPO:ref:refs/heads/main. Environment: repo:OWNER/REPO:environment:production. PR: repo:OWNER/REPO:pull_request. Tags use ref:refs/tags/.... The subject comes from the trigger context, which is why an environment-gated deploy and a branch push have different subjects — and a FIC registered for the branch won’t match the environment job.

5. On Azure, how does a FIC match the subject, and why does that matter? A standard FIC matches the subject as an exact string — no wildcards. That exactness is the safety: repo:acme/platform:ref:refs/heads/main trusts exactly one branch of one repo. A flexible FIC with a claimsMatchingExpression can pattern-match, but that deliberately widens the trust and should be used sparingly.

6. On AWS, what is the difference between StringEquals and StringLike on :sub, and which is safer? StringEquals matches the subject exactly (one ref); StringLike allows wildcards (repo:org/repo:* matches every ref/PR/tag, repo:org/* matches every repo in the org). StringEquals is safer and the default; use StringLike only with a tight, intentional pattern, because a stray * silently widens the trust to branches, PRs (including forks), and tags you didn’t mean.

7. On GCP, what does the provider’s attribute-condition do, and what happens without one? It’s a CEL expression over the token’s claims (e.g. assertion.repository=='acme/platform') that gates which external identities the pool admits. Without it, the pool admits any token from the trusted issuer — and because every GitHub repo shares the same issuer, that effectively trusts the entire public IdP. The condition (plus a non-default audience and a scoped IAM principalSet) is the boundary, not optional hardening.

8. Federation succeeded but every API call returns 403. What’s wrong? Authentication (the trust) and authorization (RBAC/IAM) are separate: the trust matched and a credential was issued, but the identity has no permissions. Fix with a least-privilege Azure RBAC assignment, an AWS permission policy, or a GCP IAM binding — at the narrowest scope the job needs.

9. A self-hosted runner fails with AADSTS700024: Client assertion is not within its valid time range, but GitHub-hosted runners work. Cause and fix? Clock skew. The token’s nbf/exp window is minutes wide; the runner’s clock has drifted (often a locked-down subnet blocking UDP/123 to public NTP), so the cloud reads the assertion as future-dated or expired. Point the runner at an internal time source and add a CI guard that fails fast on skew.

10. Why must pull_request triggers map to a read-only identity? A PR — especially from a fork — is lower trust. If the pull_request subject can satisfy a write-holding trust, a malicious PR can mutate your infrastructure. Map pull_request only to a read-only identity, and gate writes behind an environment with reviewers (subject environment:...). GitHub also withholds secrets from fork PRs by default, but the subject split is the structural defence.

11. What still has to be true after you “remove the secret” for the migration to be genuinely secretless? Every static credential must be deleted, not merely unused: client secrets (az ad app credential list), IAM access keys (aws iam list-access-keys), user-managed SA keys (gcloud ... keys list --managed-by=user). A FIC and a secret can coexist; if the secret survives, the old attack path survives and you’ve added complexity for no gain.

12. How does in-cluster federation (AKS Workload Identity / EKS IRSA / GKE WIF) reuse the same primitive? The cluster has an OIDC issuer; a Kubernetes service account is the workload’s identity; a projected SA token (the OIDC JWT) is mounted into the pod with subject system:serviceaccount:namespace:serviceaccount; the cloud trusts that issuer + subject via the same trust object (FIC / IRSA trust policy / WIF binding). The pod authenticates with the projected token — no cloud secret mounted, a per-pod identity instead of a shared node identity.

These map to AZ-500 (Azure Security Engineer) and SC-300 (Identity and Access Administrator) for the Entra/FIC and Conditional Access angles; AWS Security Specialty and SysOps/DevOps for IAM OIDC providers and AssumeRoleWithWebIdentity; and Google Professional Cloud Security Engineer for Workload Identity Federation. A compact cert mapping:

Question theme Primary cert Objective area
OIDC trust model, claims, audience AZ-500 / AWS Security / GCP Security Federated identity & token validation
Azure FIC, flexible FIC, UAMI AZ-500 / SC-300 Manage workload identities
AWS IAM OIDC provider, trust policy sub/aud AWS Security Specialty Identity federation; least privilege
GCP WIF pool, attribute condition, principalSet GCP Pro Cloud Security Workload identity federation
Conditional Access for workload identities SC-300 / AZ-500 Conditional Access; risk policies
In-cluster federation (Workload Identity/IRSA) AZ-500 / CKS / GCP Security Securing Kubernetes workloads

Quick check

  1. In one sentence, what does the cloud’s STS actually verify before it issues a credential, and what does it not check (that a separate layer handles)?
  2. Your AWS trust policy conditions on StringLike "...:sub": "repo:acme/platform:*" and omits the :aud check. Name two distinct ways this is unsafe.
  3. A GitHub Actions job fails with “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL.” What single line is missing from the workflow?
  4. On Azure, the FIC trusts repo:acme/platform:ref:refs/heads/main but the production deploy runs in a GitHub Environment. Why does it fail, and what subject should the FIC use instead?
  5. You’ve switched the pipeline to OIDC and login works. What must you still do before the migration is genuinely “secretless,” and how do you verify it on each cloud?

Answers

  1. The STS verifies the signature (against the IdP’s JWKS), issuer, audience, validity window (nbf/exp), and subject/conditions against the registered trust. It does not check what the identity is allowed to do — that authorization is a separate layer (Azure RBAC / AWS IAM / GCP IAM binding), which is why a clean federation can still 403.
  2. (a) StringLike with * trusts every ref of the repo — branch, tag, and PR (including fork PRs if enabled), not just main — so any can assume the role. (b) Omitting :aud removes the replay guard. Fix: StringEquals on the exact subject and pin :aud to sts.amazonaws.com.
  3. permissions: id-token: write — without it the runner cannot mint the OIDC token at all.
  4. An environment job’s subject is repo:acme/platform:environment:production, not ref:refs/heads/main, so the exact-match FIC misses and the exchange returns AADSTS70021. Register a FIC with the environment:production subject (and gate it with reviewers).
  5. Delete every static credential — it isn’t gone just because it’s unused. Verify with az ad app credential list (Azure; a UAMI can’t have one), aws iam list-access-keys (AWS), and gcloud iam service-accounts keys list --managed-by=user (GCP, expect only Google-managed). Then flag any new static credential so the weak link can’t grow back.

Glossary

Next steps

You can now make any cloud-touching pipeline secretless and scope its trust precisely. Build outward:

Workload Identity FederationOIDCSecretless CI/CDGitHub ActionsFederated CredentialsAWS IAM OIDCGCP Workload IdentityEntra ID
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading