HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 642 min read

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 aud condition — 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.