Cloud intrusions that begin with a credential almost always begin with a static one: an access key pasted into a CI secret, left in a .env, or baked into a container layer. Static keys leak because they are copyable and they never expire on their own. Workload identity removes the copyable part.
What federation actually does
Your CI system can already prove who it is. It mints a short-lived signed OIDC token describing the job: which repository, which branch or environment, which workflow. Rather than storing a key, you let the cloud verify that token against the provider's public keys and exchange it for temporary credentials. The result:
- nothing long-lived is stored anywhere
- the credential expires in minutes and is scoped to one job
- a credential scraped from a build log is already dead
In AWS terms the pipeline calls sts:AssumeRoleWithWebIdentity with the token and receives temporary credentials for a role. GCP and Azure have the same shape with different names.
The mistake that undoes it: the trust policy
Federation is only as strong as the condition describing whose token you accept. The subject claim carries the workload's identity; match it loosely and any project on that public provider can assume your role, including one the attacker creates.
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
Reviewing one of these is a four-line checklist:
- Is the audience pinned? Without an
audcondition the role accepts tokens minted for a different audience entirely. - Is the subject pinned to org, repo and ref? A subject condition of
repo:my-org/*trusts every repository in the org. A condition that begins with a wildcard, or is absent, trusts the entire public provider - that is a role anyone on the internet can assume. - Is a wildcard doing real work?
StringLikeis fine where the unconstrained segment is genuinely variable, and dangerous where it swallows the identity. - Are the role's permissions the ones this pipeline needs? Federating into a shared "ci-admin" role just moves the problem.
Detections worth having afterwards
Federation makes the audit trail cleaner, which makes alerting practical:
AssumeRoleWithWebIdentitywhere the subject does not match the repository expected for that role. Stack the observed subject per role and read anything new.- Any
CreateAccessKeyonce you have migrated. In an account that is supposed to be keyless, minting a long-lived key is an event, not a chore. - First use of a long-lived key from a new ASN or country, and use of a key that has been dormant for months.
AssumeRolefor a CI-only role from an IP outside your CI provider's published ranges.
While you migrate, keep the leftovers visible. The IAM credential report gives you one row per user with key age and last-used dates:
aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 -d
Anything unused past your rotation window is a free deletion, and deletions are the only mitigation that cannot regress.
Where it does not apply
Federation needs an issuer you can trust: CI systems, Kubernetes service accounts exchanged for cloud credentials, and the managed identities attached to VMs and functions. It does not cover a laptop script or a SaaS product whose integration page offers one field labelled "access key". For those, scope the key to the smallest action set that works, rotate on a schedule you will genuinely execute, and alert on its use from anywhere unexpected. The target is not zero keys. It is zero keys whose theft you would fail to notice.
