open atlas
↑ Back to track
CI/CD pipelines CICD · 10 · 02

OIDC to the cloud: short-lived tokens instead of long-lived AWS keys, and pinning the sub claim

OIDC lets a job mint a short-lived (~1h) cloud token via AssumeRoleWithWebIdentity instead of storing a forever AWS key. The trust policy must pin the sub claim or any repo can assume your role.

CICD Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The leaked key was found by accident — a security researcher running a public-repo scanner pinged the team: an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY pair sat in a fork’s CI logs from a debug env dump committed two years prior. The key was an IAM user’s static credential — no expiry, full deploy permissions — copied into GitHub Secrets because that was the obvious way to let CI push to S3 and update ECS. It had been valid the entire time. Nobody had rotated it because nobody had a reason to; static keys do not expire and rotation is manual toil. The remediation was not “rotate and move on.” It was “stop storing cloud keys in CI at all.” They switched to OIDC: the job now requests a fresh credential from AWS STS at runtime, scoped to one role, valid for an hour, tied cryptographically to this repository’s workflow. There was no longer a secret to leak — and the trust policy they wrote made sure no other repo could request the same role.

The trade: a stored key vs. a minted token

The old model stores a long-lived cloud credential in GitHub Secrets. An IAM access key has no expiry: it works until someone manually rotates or deletes it. If it leaks — a log dump, a compromised Action, a screenshot — it is a standing, full-permission credential an attacker uses at leisure, and you often learn about it from a third party. Rotation is manual, so in practice these keys live for years.

OIDC inverts this. GitHub Actions runs an OIDC identity provider. At runtime a job can request a signed JSON Web Token from GitHub that asserts facts about the run — which repo, which ref, which workflow, which environment — in its claims. The job hands that JWT to the cloud’s token service; the cloud verifies GitHub’s signature, checks the claims against a trust policy you configured, and returns a short-lived credential. On AWS the call is sts:AssumeRoleWithWebIdentity, and the returned credentials default to one hour (configurable, capped by the role’s max session duration). There is no secret stored anywhere: the JWT is minted fresh each run and is useless after it expires.

permissions:
  id-token: write     # REQUIRED — lets the job request the OIDC JWT
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@<pinned-sha>
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: eu-central-1
          # no aws-access-key-id / aws-secret-access-key — none exist

That id-token: write permission is the gate: without it the job cannot request the JWT, which is exactly why you scope it to only the job that deploys.

Quiz

A team migrates from a stored AWS_SECRET_ACCESS_KEY to OIDC. What is the concrete security difference in what an attacker gains from compromising the CI?

The claim that makes or breaks it: sub

Before you write a trust policy, ask yourself: what exactly does the cloud verify? If your answer is “the signature,” you have a wide-open role. The signature proves only that GitHub Actions minted the token — not which of the hundreds of millions of repos triggered the run. The one condition that closes the gap is the sub claim.

GitHub signs the JWT, so the cloud knows it genuinely came from GitHub Actions. But every GitHub repository’s workflow gets a token signed by the same issuer. If your AWS trust policy only checks “is this token from GitHub’s OIDC issuer?”, then any GitHub user’s workflow can request your role and assume it. The control that scopes the trust to your repo is the sub (subject) claim, which encodes the run’s identity — and you pin it in the trust policy’s condition:

{
  "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"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
    }
  }
}

The sub string is the lock. repo:my-org/my-repo:ref:refs/heads/main means only that repo, only on the main branch, can assume the role. You can narrow further (:environment:production) or, for tag releases, :ref:refs/tags/*. The classic failure is a too-loose pattern — repo:my-org/* lets any repo in the org assume the role, and a wildcard like repo:* (or omitting the sub condition entirely) lets the entire internet’s GitHub Actions assume it. Also pin aud: GitHub’s default audience is the configured value, and checking it prevents a token minted for a different relying party from being replayed at your STS.

Why this works

Why is forgetting the sub condition so catastrophic, given the token is still GitHub-signed? Because the signature only proves GitHub Actions minted it — not whose Actions. The OIDC issuer is shared by all of GitHub. An attacker creates a throwaway repo, writes a one-line workflow that requests a token with aud: sts.amazonaws.com, and gets a perfectly valid, GitHub-signed JWT. If your role’s trust policy checks only the issuer and audience, AWS accepts that token and hands the attacker your role’s credentials. The sub claim is the only thing that says “this run belongs to my-org/my-repo, not someone-else/evil-repo.” Pinning it is not hardening on top of OIDC — it is the part of OIDC that makes it secure at all.

Quiz

An AWS trust policy for a GitHub OIDC role checks the audience claim and that the token is from token.actions.githubusercontent.com, but its sub condition is StringLike repo:my-org/*. What is the exposure?

Recall before you leave
  1. 01
    Walk through the OIDC-to-AWS exchange and name the one cloud-side control that actually scopes trust to your repo.
  2. 02
    Why is a sub condition of repo:my-org/* or a missing sub condition dangerous, and how do you scope it correctly?
Recap

OIDC removes the long-lived cloud key from CI entirely. Instead of storing a non-expiring AWS access key in GitHub Secrets — a standing, full-permission credential that leaks via log dumps or compromised Actions and lives for years because rotation is manual — a job mints a fresh credential at runtime. It declares id-token: write, requests a GitHub-signed JWT scoped to an audience, and exchanges it via sts:AssumeRoleWithWebIdentity for a short-lived (~1h) credential. There is nothing static to steal. But OIDC’s safety lives or dies on the cloud trust policy. GitHub signs JWTs for every repository on GitHub from one shared issuer, so a signature alone proves only that GitHub Actions minted the token, not whose. The sub claim — repo:my-org/my-repo:ref:refs/heads/main — is the lock that scopes the role to your repo and ref; pin it in a StringLike condition, and pin aud too. A loose pattern like repo:my-org/* trusts the whole org, and a missing sub condition lets any GitHub user’s throwaway workflow assume your role with a perfectly valid token. So: scope id-token: write to the one deploy job, pin sub and aud, narrow to a ref or environment, and you have a credential that cannot be leaked because it never persists and cannot be borrowed because the trust policy names you. Now when you see a trust policy, the first thing you check is whether the sub condition is present and pinned to an exact repo — because that line is either there or the role is effectively public.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.