OIDC Federation for CI (Keyless Cloud Credentials)
keyless cloud credentials
Cloud infrastructurecloud infrastructure / security / CI
The myth
"Our deploy key lives in GitHub Secrets and we rotate it every 90 days. That's secure enough."
Actually
A static AWS_ACCESS_KEY_ID is a bearer credential: it works from any IP on earth, for anyone who ever sees it, until you rotate it. And it leaks in boring ways — a debug step that prints env, a compromised transitive npm dependency running in your build, a contractor's old laptop. Rotation shrinks the window. OIDC removes the credential entirely.
The correct mental model
GitHub Actions can mint a short-lived signed JWT that describes the job:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:acme/api:ref:refs/heads/main",
"aud": "sts.amazonaws.com",
"exp": <~10 min>
}
AWS is told to trust that issuer's public keys. The job trades the JWT for temporary STS creds (~1h). Nothing secret is stored anywhere.
Shift in thinking: you stop protecting a string and start writing an assertion about who may assume this role.
The whole client side
permissions:
id-token: write # without this, no JWT is minted
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::1234:role/deploy
aws-region: eu-west-1
The gotcha that trips almost everyone
The trust policy. People copy this:
"StringLike": { "...:sub": "repo:acme/api:*" } ❌
That wildcard matches every branch, every tag, every environment in the repo. So git push origin evil-branch with a workflow that runs aws s3 rm --recursive assumes your production role. You replaced "anyone with the secret" with "anyone with push access" — better, but not what you meant.
Pin it exactly:
"StringEquals": {
"...:aud": "sts.amazonaws.com",
"...:sub": "repo:acme/api:ref:refs/heads/main"
} ✅
Or scope to a protected environment and get human approval for free:
"...:sub": "repo:acme/api:environment:production"
Three more real mistakes
- Dropping the
audcondition — always assert it, it costs one line. - Forgetting
id-token: write, then losing an hour to "Credentials could not be loaded". The token is opt-in per job. - Thinking short-lived = safe. Your creds now expire in 1h, so lifetime is no longer the risk — scope is. Attach a least-privilege policy to that role anyway.
Same protocol elsewhere
GCP Workload Identity Federation, Azure federated credentials, Vault's JWT auth, and Kubernetes projected service-account tokens are all this exact pattern: issuer + sub claim + a trust rule. Learn it once.
Your exercise
Open one repo that deploys to a cloud. Find where its credential lives. Then write out, by hand, the exact sub string that job would present — including the branch or environment. Now ask: would a StringLike rule you'd realistically write match anything broader than that one string? That gap is your blast radius.