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:
- Explain the OIDC federation trust model end to end — IdP mints a signed JWT, cloud STS validates it against the published JWKS, claims match a pre-declared trust, a short-lived credential is returned — and name where each step happens on Azure, AWS, and GCP.
- Configure GitHub Actions to deploy to Azure with a federated identity credential and no client secret (
azure/login@v2,id-token: write). - Configure GitHub Actions to assume an AWS IAM role via an OIDC provider and
aws-actions/configure-aws-credentials, with a trust policy that pinssubandaudcorrectly. - Configure GitHub Actions (and GitLab) to federate to GCP Workload Identity Federation, mapping and conditioning attributes so only the intended repo/branch authenticates.
- Write the exact subject claims, audience values and conditions for branch, tag, environment, and PR triggers — and choose exact-match vs pattern-match deliberately.
- Federate in-cluster Kubernetes workloads (AKS Workload Identity, EKS IRSA / Pod Identity, GKE WIF) so pods reach cloud APIs without mounted secrets.
- Recognise and prevent the standard misconfigurations — over-broad subject, wrong audience, the
pull_requestfork hole, wildcard repo trust, orphaned static secrets. - Migrate an estate off PATs, client secrets, access keys, and JSON keys, verify each cut-over, and prove no static credential remains.
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.
- The job requests a token from its IdP. In GitHub Actions the runner exposes
ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKENto a job that declaredpermissions: id-token: write; the login action asks for a token with a specificaud, and GitHub mints and signs a JWT stamped with this job’s claims (repo, ref/environment, run id, a few-minute lifetime). - The job presents the token to the cloud’s STS. As a
client_assertionto the Entra token endpoint (Azure), as the argument tosts:AssumeRoleWithWebIdentityagainst a role ARN (AWS), or exchanged atsts.googleapis.comthen optionally used to impersonate a service account (GCP). - 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.
- The cloud checks issuer, audience, and time window.
issmust equal a trusted issuer;audmust match what the trust expects;nbf ≤ now ≤ expmust hold against the cloud’s clock — which is why runner clock skew surfaces as a “credential” error. - The cloud matches the subject and any conditions. Azure compares
subto the FIC’s exactsubject(or itsclaimsMatchingExpression); AWS evaluates the trust policy’sStringEquals/StringLikeon:sub/:aud; GCP evaluates the provider’s CELattribute-condition. No match → “no matching federated identity record” / “not authorized to perform sts:AssumeRoleWithWebIdentity.” - 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 federate against
environment:production, and that environment carries required reviewers. The subject only resolves when a job runs in that environment, so the production identity is unusable from an arbitrary branch or fork. - Plan / read-only jobs (including
pull_requestones) federate against a separate identity holding only Reader plus plan-time data roles — never write. PRs, especially from forks, are lower trust and must never touch a principal that can mutate infrastructure.
# 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 execution — iss (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
- Scope the subject to the narrowest workload that needs the trust — one branch or one environment, not the whole repo. Exact-match by default; reach for patterns only deliberately and document why.
- Always pin the audience on both request and trust (
api://AzureADTokenExchange,sts.amazonaws.com, or your GCP provider’s value). It’s the cross-cloud replay guard; omitting it (especially on AWS/GCP) is a real hole. - Split read from write across separate identities. Plan/PR jobs use a Reader-only identity; deploys use a write identity gated by an environment with reviewers. A PR must never satisfy a write trust.
- Gate production behind a GitHub Environment with required reviewers, federated against the
environment:...subject so the production identity is unusable outside that gated context. - Authorize with least privilege at the narrowest scope — resource-group (Azure), single-resource policies (AWS), single-SA bindings (GCP). The RBAC/IAM you attach is the real blast radius.
- Manage trust objects as code (Terraform/Bicep), reviewed in PRs like firewall rules. A subject change is a security change.
- Inventory and review every FIC / OIDC provider / WIF provider on a cadence. Each is a standing trust that can rot — a stale or over-broad subject is what an audit should catch.
- Delete every static credential after federation is verified. Coexistence leaves the old path open; the migration isn’t done until the secret is gone.
- Keep the issuer and subject byte-exact — no trailing slash, correct case, the precise subject format for the trigger. Most first-time failures are a typo here.
- For self-hosted runners, treat clock sync as a dependency — internal NTP plus a fail-fast skew guard, so a drift fails loud instead of an opaque “assertion not within valid time range.”
- Add Conditional Access / risk policies on top (Azure) — a location policy on the deploy principal makes a stolen token from elsewhere useless even within its lifetime.
- Prefer user-assigned managed identities over app registrations on Azure — fewer objects, no app secret can exist at all, FICs attach cleanly.
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.
- Least privilege is the blast radius — set it like one. Federation authenticates; RBAC/IAM authorizes. Keep deploy identities at resource-group/single-resource scope, split read from write, and never leave subscription-root Contributor,
AdministratorAccess, or projectEditoron a CI identity. A perfectly federated identity withOwneris a perfectly federated path to your whole cloud. - The audience is a security control, not boilerplate. On AWS and GCP especially, omitting the
audcheck leaves the trust open to tokens minted for other purposes/clouds. Pin it everywhere. - Constrain where on Azure with Conditional Access for workload identities. A location policy on the service principal (Workload Identities Premium) blocks sign-ins from outside known networks, so a stolen token used elsewhere is rejected — detailed in Locking Down Workload Identities: Conditional Access, Risk Detection, and Going Secretless.
- Treat the trust object as a sensitive resource. Whoever can create or edit a FIC/OIDC provider/WIF provider can grant your cloud to an external repo. Restrict that permission, require PR review on the IaC, and alert on changes.
- Don’t trust forks with write. Map
pull_requestto read-only; rely on the platform withholding secrets from fork PRs but defend in depth with the subject split. - Don’t leak the OIDC token in logs. The claim-decoding debug step is for setup only — a leaked token is still a (short-lived) credential.
- Audit the FIC/provider inventory like firewall rules. A standing trust with an over-broad subject is a latent grant; review on the access-review cadence, pairing with the secret-hygiene program in Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation.
- Verify the issuer chain for private IdPs. A self-managed GitLab or cluster issuer must expose a JWKS the cloud can reach and verify; an unreachable or spoofable endpoint undermines the whole model.
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
- 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)?
- Your AWS trust policy conditions on
StringLike"...:sub": "repo:acme/platform:*"and omits the:audcheck. Name two distinct ways this is unsafe. - A GitHub Actions job fails with “Unable to get ACTIONS_ID_TOKEN_REQUEST_URL.” What single line is missing from the workflow?
- On Azure, the FIC trusts
repo:acme/platform:ref:refs/heads/mainbut the production deploy runs in a GitHub Environment. Why does it fail, and what subject should the FIC use instead? - 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
- 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. - (a)
StringLikewith*trusts every ref of the repo — branch, tag, and PR (including fork PRs if enabled), not justmain— so any can assume the role. (b) Omitting:audremoves the replay guard. Fix:StringEqualson the exact subject and pin:audtosts.amazonaws.com. permissions: id-token: write— without it the runner cannot mint the OIDC token at all.- An environment job’s subject is
repo:acme/platform:environment:production, notref:refs/heads/main, so the exact-match FIC misses and the exchange returnsAADSTS70021. Register a FIC with theenvironment:productionsubject (and gate it with reviewers). - 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), andgcloud 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
- Workload identity federation — letting a workload authenticate to a cloud with a short-lived OIDC token from a trusted external identity provider instead of a stored secret.
- Identity provider (IdP) — the service that mints signed OIDC tokens for workloads (GitHub’s token service, GitLab CI, a Kubernetes cluster’s OIDC issuer).
- OIDC token (JWT) — a short-lived, signed assertion carrying
iss,sub,aud,exp/nbf, and provider-specific claims; the thing presented instead of a secret. - Issuer (
iss) — the URL identifying who minted the token; the cloud trusts specific issuers and verifies their signatures via JWKS. - Subject (
sub) — the claim identifying the specific workload (e.g.repo:owner/repo:ref:refs/heads/main,system:serviceaccount:ns:sa); the lock that scopes trust. - Audience (
aud) — the claim declaring who the token is for; the cross-cloud/cross-purpose replay guard. - JWKS — the IdP’s public signing keys, published at a well-known endpoint, used by the cloud to verify the token’s signature.
- Federated identity credential (FIC) — Azure’s trust object on an app registration or user-assigned managed identity, declaring a trusted issuer + subject + audience.
- Flexible FIC — a FIC that matches a
claimsMatchingExpression(pattern) instead of an exact subject; wider trust, used sparingly. - IAM OIDC identity provider (AWS) — the registration of an external OIDC issuer in an AWS account as a trusted federation source.
- Role trust policy (AWS) — the policy on an IAM role declaring who may
AssumeRoleWithWebIdentityand thesub/audconditions that gate it. AssumeRoleWithWebIdentity— the AWS STS call that exchanges an OIDC token for temporary role credentials.- Workload Identity Pool / Provider (GCP) — GCP’s federation objects: a pool of external identities and a provider mapping/conditioning the issuer’s claims.
- Attribute condition (GCP) — a CEL expression on the provider that gates which external identities are admitted; GCP’s subject lock.
principalSet(GCP) — the IAM member form that binds external (federated) identities, scoped by a mapped attribute, to a role or impersonated service account.- Client assertion — the OIDC token presented to Entra as proof in the JWT-bearer exchange (
client_assertion_type=...jwt-bearer). - STS (security token service) — the cloud endpoint that validates the OIDC token and issues a short-lived cloud credential.
- IRSA / Pod Identity (EKS) — AWS’s two in-cluster identity mechanisms: IRSA (OIDC federation on the SA subject) and Pod Identity (association-based, no per-cluster OIDC provider).
- Conditional Access for workload identities — Entra policies (location, risk) applied to a service principal so a token is usable only from approved contexts.
Next steps
You can now make any cloud-touching pipeline secretless and scope its trust precisely. Build outward:
- Next: Locking Down Workload Identities: Conditional Access, Risk Detection, and Going Secretless — the hardening layer that constrains where a federated token can be used.
- Related: Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns for Azure Workloads — the Azure identity primitive these FICs attach to.
- Related: Building a Secure OIDC Confidential Client in Entra ID: App Registrations, Secrets, and Workload Identity Federation — the app-registration route and OIDC fundamentals.
- Related: Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation — find and kill the static secrets federation lets you delete.
- Related: Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars — where secretless workload identity fits in a zero-trust identity pillar.