CoursesAdvanced cloud securityWorkload identity federation without static keys

Workload identity federation without static keys

OIDC to AWS/GCP/Azure and killing long-lived cloud keys.

Advanced35 min · lesson 2 of 15

A long-lived cloud access key is a house key you mailed to a contractor. It opens the door today, next year, and the morning after they were let go, and you have no real idea how many copies got cut along the way. Workload identity federation swaps the mailed key for a doorman. The doorman checks a photo ID at the moment someone shows up, and lets them in for exactly one shift. Nothing gets left under the mat.

The highest-value identity change you can make in any cloud is deleting the standing keys and letting each workload prove who it is, one run at a time, with a token that is worthless a few minutes later. This lesson wires that up on Amazon Web Services (AWS), Google Cloud (GCP), and Microsoft Azure from a single continuous integration job, with not one stored secret in the whole pipeline.

The handshake in plain terms

Federation means one system agrees to trust identities minted by another, the way a conference lets you in on a badge your employer printed instead of issuing you a fresh membership card. The trusted badge-printer here is an OpenID Connect (OIDC, an identity layer that sits on top of the OAuth 2.0 login protocol) provider. That is your CI (continuous integration) platform, a Kubernetes cluster, or a peer cloud, each identified by an issuer URL that shows up inside the token as the iss (issuer) claim.

When a job runs, the provider signs a short JSON Web Token (JWT, a small bundle of claims with a cryptographic signature stuck on the end). The claims describe the run: sub (subject, meaning which repository, branch, service account, or pod this is), aud (audience, meaning who the token is intended for), plus extras like the repo name and the commit hash. The workload hands that JWT to the cloud's Security Token Service (STS on AWS, the STS token endpoint at sts.googleapis.com on GCP, the Microsoft Entra ID token exchange on Azure, where Entra ID is the identity service formerly called Azure Active Directory). STS is the one component that ever hands out credentials. Federation only teaches it to accept a signed attestation from outside in place of a stored password.

Two gates, in order: signature, then claims

The exchange is a door with two guards, and they check in a fixed order. The first guard checks the seal. STS pulls the provider's public signing keys from its OIDC discovery document (a JSON file served at /.well-known/openid-configuration, whose jwks_uri field points at the current keys) and verifies the JWT signature. A forged or edited token dies right here, because the workload never held a signing key. It only ever received a token the identity provider signed for it.

The second guard checks the guest list. STS compares the verified claims against the trust conditions you configured. The audience has to equal the value you registered, and the subject (or, on GCP, the mapped attributes) has to satisfy your condition. Only when both guards wave the token through does STS mint temporary credentials, usually good for fifteen minutes to an hour, scoped to a single role or service account, and dead the instant they expire. There is no secret sitting at rest to steal, and a captured token that already expired buys an attacker nothing.

This is also why short-lived federation beats key rotation. Rotation shrinks the window a leaked key stays useful, but the key still exists, still sits in a file or a secrets manager, and still leaks. Federation removes the standing credential completely. There is nothing to rotate because there is nothing stored.

One keyless token exchange
1Job starts
runner asks GitHub for an OIDC JWT (needs id-token: write)
2IdP signs the token
short JWT: sub, aud, repo, commit, a few minutes of life
3Runner calls cloud STS
hands over the JWT, holds no stored key
4Gate 1: signature
STS fetches the jwks_uri keys and rejects any forgery
5Gate 2: claims
aud and sub must match your trust conditions
6Temp creds minted
one role, 15 min to 1 hour, expire on their own
Two gates, signature then claims. Nothing is kept between runs.

The same job, keyless, on three clouds

The mental model is identical on every provider. Only the nouns change. Here is one GitHub Actions workflow that deploys to all three clouds, with zero access-key secrets in the file. The id-token: write permission is what lets the runner ask GitHub for an OIDC JWT in the first place. Leave it out and the whole thing fails closed, which is the safe direction to fail.

.github/workflows/deploy.yml
# Not one access-key secret anywhere in this file.
permissions:
id-token: write # let the runner request an OIDC JWT
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v6 # AWS
with:
role-to-assume: arn:aws:iam::222222222222:role/gha-deploy
aws-region: eu-west-1
- uses: google-github-actions/auth@v3 # GCP
with:
workload_identity_provider: projects/738294015627/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: [email protected]
- uses: azure/login@v3 # Azure
with:
client-id: 8f3c1e60-4d2a-4b7e-9f1a-222233334444
tenant-id: 7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
subscription-id: 00000000-1111-2222-3333-444455556666

AWS first. You register GitHub's issuer once as an IAM (Identity and Access Management) OIDC provider, then write a role trust policy that spells out who may assume the role.

terminal
# One-time: register GitHub's OIDC issuer as a trusted provider.
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list ffffffffffffffffffffffffffffffffffffffff
output
{
"OpenIDConnectProviderArn": "arn:aws:iam::222222222222:oidc-provider/token.actions.githubusercontent.com"
}

For well-known issuers, AWS validates the signing certificate against its own trusted certificate authorities, so the --thumbprint-list value goes unused and a placeholder is fine. The client-id-list is the audience AWS will demand, sts.amazonaws.com. The trust policy is the actual lock. Read it like production code, because that is exactly what it is.

trust-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::222222222222: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/api:ref:refs/heads/main"
}
}
}
]
}

Under the hood, the configure-aws-credentials action makes one call, the same one you could run by hand. DurationSeconds can range from 900 (fifteen minutes) up to 43200 (twelve hours), capped by the role's own max-session setting, and it defaults to 3600.

terminal
# What configure-aws-credentials runs for you:
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::222222222222:role/gha-deploy \
--role-session-name gha-run-8123 \
--web-identity-token "$ACTIONS_ID_TOKEN" \
--duration-seconds 3600
output
{
"Credentials": {
"AccessKeyId": "ASIAXOIDC7EXAMPLENF4Q",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"SessionToken": "IQoJb3JpZ2luX2VjEJr...truncated...==",
"Expiration": "2026-07-22T15:42:07+00:00"
},
"SubjectFromWebIdentityToken": "repo:acme/api:ref:refs/heads/main",
"AssumedRoleUser": {
"AssumedRoleId": "AROAXOIDC7EXAMPLE:gha-run-8123",
"Arn": "arn:aws:sts::222222222222:assumed-role/gha-deploy/gha-run-8123"
},
"Provider": "arn:aws:iam::222222222222:oidc-provider/token.actions.githubusercontent.com",
"Audience": "sts.amazonaws.com"
}

On GCP you create a workload identity pool, add an OIDC provider inside it, map the GitHub claims onto Google attributes, and refuse anything that is not acme/api's main branch at the provider itself. Then you let that federated identity impersonate the deploy service account.

terminal
# Create the pool, then an OIDC provider that maps GitHub claims onto
# Google attributes and rejects anything not from acme/api's main branch.
gcloud iam workload-identity-pools create github-pool \
--location=global --display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location=global --workload-identity-pool=github-pool \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository=='acme/api' && assertion.ref=='refs/heads/main'"
# Let the federated identity impersonate the deploy service account.
gcloud iam service-accounts add-iam-policy-binding \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/738294015627/locations/global/workloadIdentityPools/github-pool/attribute.repository/acme/api"
output
Created workload identity pool [github-pool].
Created workload identity pool provider [github-provider].
Updated IAM policy for serviceAccount [[email protected]].
bindings:
- members:
- principalSet://iam.googleapis.com/projects/738294015627/locations/global/workloadIdentityPools/github-pool/attribute.repository/acme/api
role: roles/iam.workloadIdentityUser
etag: BwYXp3q9tR0=
version: 1

You can also skip the service account and bind that principalSet:// member straight onto a resource for keyless direct access, no impersonation hop. Google asks you to attach an attribute-condition whenever the issuer is a shared provider like GitHub, precisely so a token from some other org's repo cannot walk in. That condition is doing the same job the AWS sub condition does.

On Azure you attach a federated identity credential to an app registration (or to a user-assigned managed identity). No client secret is ever created.

terminal
# Attach a federated credential to the app registration. No client secret.
az ad app federated-credential create \
--id 8f3c1e60-4d2a-4b7e-9f1a-222233334444 \
--parameters '{
"name": "gha-acme-api-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:acme/api:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
output
{
"audiences": [
"api://AzureADTokenExchange"
],
"description": null,
"id": "b1e7d3a0-9f42-4c88-8a11-6d0f2e4a7c9b",
"issuer": "https://token.actions.githubusercontent.com",
"name": "gha-acme-api-main",
"subject": "repo:acme/api:ref:refs/heads/main"
}

Azure accepts exactly one audience, and the value it wants is api://AzureADTokenExchange. Two limits are worth knowing before you script this. You can attach at most 20 federated identity credentials per app registration or managed identity, so plan the subject values rather than minting one per branch. And a freshly created credential takes a short time to replicate, so a token request in the first minute can fail with AADSTS70021: No matching federated identity record found for presented assertion. Add a retry and the error clears itself.

Trust conditions are the whole security boundary

Once federation is wired, the cloud will hand credentials to anyone whose token satisfies your conditions. Those conditions are the entire security model, not a footnote. A subject pinned to repo:acme/api:ref:refs/heads/main accepts tokens minted only on that branch of that repo. Loosen it to repo:acme/api:* and you now trust every branch, every tag, and every pull-request ref in the repository. Any contributor who can push a branch can assume a role you meant for production deploys.

The audience claim is a second, independent lock. Asserting aud=sts.amazonaws.com on AWS, api://AzureADTokenExchange on Azure, or the pool's provider URI on GCP stops a token minted for some other relying party from being replayed against yours. That replay has a name, the confused deputy, where a token meant for service A gets pointed at service B and B honors it. Pin the audience and the replay bounces off.

A wildcard subject is worse than a stored key
repo:acme/api:* looks scoped, it even names your repo, but it matches every branch, every tag, and every pull-request ref. Any pushable ref inherits the role, so it is more dangerous than a leaked key because it feels safe and sails through review. Pin sub to an exact ref or a GitHub Environment, and always assert aud. Watch privileged triggers too: a pull_request_target or workflow_run job runs with the base repo's secrets and identity, so if it checks out untrusted fork code, an attacker's pull request can run commands with your cloud credentials. For a plain pull_request event the subject is repo:acme/api:pull_request, which will not match a main-branch pin, so keep that pin tight and never run fork code in a privileged workflow.

Prove the key is gone and the role is live

Wiring it is half the job. Checking it is the other half. Inside the deploy job, ask the cloud who you are. On AWS that is one call, and the answer should be an assumed-role identity, never an IAM user.

terminal
# Run inside the deploy job. Who am I, really?
aws sts get-caller-identity
output
{
"UserId": "AROAXOIDC7EXAMPLE:gha-run-8123",
"Account": "222222222222",
"Arn": "arn:aws:sts::222222222222:assumed-role/gha-deploy/gha-run-8123"
}

The Arn (Amazon Resource Name, AWS's unique identifier for a resource) starting with arn:aws:sts:: and assumed-role is your proof: this job is running on a temporary, federated identity that expires on its own. On GCP, gcloud auth list shows the impersonated service account and no key file on disk. On Azure, az account show reports the federated login. Now go remove the thing you replaced. List the old CI user's access keys and confirm the count is zero, so there is no static credential left for anyone to find, in a repo secret, a laptop, or an old backup.

terminal
# The retired static user should have nothing left to leak.
aws iam list-access-keys --user-name ci-deploy-legacy
output
{
"AccessKeyMetadata": []
}

The same trick inside Kubernetes

The identical exchange runs inside a Kubernetes cluster. A pod is handed a projected service-account token, which is a short-lived OIDC JWT written to a file inside the container and refreshed automatically before it expires. AWS IRSA (IAM Roles for Service Accounts, on Amazon's Elastic Kubernetes Service, EKS), GKE Workload Identity (on Google Kubernetes Engine), and AKS Workload Identity (on Azure Kubernetes Service) each trade that token for cloud credentials, with no keys on the node and no secret object mounted in the pod. You wire it with an annotation on the Kubernetes service account, one shape per cloud.

serviceaccounts.yaml
# In real life these live on separate clusters; shown together so you
# can see the shape. No Secret objects, no keys stored on the nodes.
apiVersion: v1
kind: ServiceAccount
metadata:
name: deployer
annotations:
# AWS IRSA: which IAM role this SA's pods may assume
eks.amazonaws.com/role-arn: arn:aws:iam::222222222222:role/gha-deploy
# GKE Workload Identity: which Google service account to impersonate
iam.gke.io/gcp-service-account: [email protected]
# AKS Workload Identity: the Entra app / managed-identity client ID
azure.workload.identity/client-id: 8f3c1e60-4d2a-4b7e-9f1a-222233334444

AKS also wants the pod template labeled azure.workload.identity/use: "true" so the admission webhook injects the token, and newer EKS clusters can use EKS Pod Identity instead of the IRSA annotation, swapping the OIDC tag for an on-node agent. The trade is the same either way: no static keys, no rotation. At scale this is the whole operational argument. There are no keys to rotate, no secrets to copy across dozens of accounts or three clouds, and no secrets-manager line item to store and audit them. The identity is the workload itself.

Getting through the door is only the first check. Federation decides which role or service account a workload may assume. What that principal can then actually do is a separate question, answered by IAM policy evaluation, permission boundaries, and the privilege-escalation paths the next lesson takes apart.

Quick check
01An attacker fully controls the CI runner and edits the OIDC token before sending it to AWS STS. Why does the exchange still fail?
Correct — the signature gate runs first, and the workload only ever receives a token it cannot forge.
Incorrect — there is no such encryption step, and the role has no key involved in validation.
Incorrect — a freshly forged token is not expired, and expiry never proves authenticity.
Incorrect — the action only requests and forwards the token; signature checking happens server-side at STS.
02A role trusts issuer token.actions.githubusercontent.com and asserts aud=sts.amazonaws.com. You want it assumable only from main of acme/api. Which condition actually enforces the branch?
Incorrect — aud proves the relying party and blocks replay, but says nothing about which branch minted the token.
Correct — sub carries the exact ref, so a token from any other branch, tag, or PR fails the StringEquals check.
Incorrect — the thumbprint concerns the issuer's TLS cert, is ignored for well-known IdPs, and cannot scope a branch.
Incorrect — duration sets how long the credentials live, not who is allowed to mint them.
03Inside your deploy job, aws sts get-caller-identity returns Arn arn:aws:iam::222222222222:user/ci-deploy-legacy. What does this tell you?
Incorrect — assumed-role sessions always show arn:aws:sts::...:assumed-role/, never a :user/ ARN.
Incorrect — there is no thumbprint fallback; a bad issuer cert makes the exchange fail outright, it does not downgrade.
Correct — a :user/ ARN means a long-lived key is still in play, so federation is not actually wired in.
Incorrect — an expired token causes an auth error, not a swap to a user identity.

Try this

Run aws sts get-caller-identity on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a wildcard subject is worse than a stored key. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related