OIDC federation from CI

GitHub/GitLab to AWS with zero stored keys.

Intermediate30 min · lesson 2 of 6

Storing a long-lived AWS access key in your CI (continuous integration) system's secret store is like hiding a spare house key under the doormat. It's convenient, it's invisible until someone looks, and it opens your door for as long as the lock exists. OIDC (OpenID Connect) federation swaps the doormat for a front desk with a guard. Every pipeline run walks up, shows signed proof of exactly who it is, and AWS hands back a visitor badge that stops working within the hour. Nothing sits under the mat waiting to be found. Nothing to rotate on a calendar. Nothing left behind when the job ends.

A few plain definitions before the commands start. Your CI platform is GitHub Actions, GitLab CI, Buildkite, and friends: the robots that build, test, and ship your code on every push. OIDC is an open standard where one trusted party vouches for an identity by handing over a digitally signed token, and that signed vouch is the whole trick. Federation means AWS agrees to accept identities that an outside provider vouches for, instead of minting its own IAM (Identity and Access Management) users and long-lived keys for them. Stitch those together: your CI platform vouches for one specific job run, and AWS trades that proof for short-lived credentials scoped to a single role.

The failure this deletes

A long-lived access key sitting in a CI secret store has three properties that should make you nervous. It keeps working until a human remembers to rotate it. It works from any internet-connected machine on earth, not only your runners. And copying it leaves no fingerprint, so you can be robbed without ever noticing. CI systems pile up exactly these secrets, which is why attackers go straight for them. The 2023 CircleCI breach forced every customer to rotate every secret the platform had ever stored. Keys leak without a breach too: printed by a careless debug step, read by a malicious third-party action you pulled into your workflow, or lifted off a runner nobody isolated. A key that leaks in January still deploys to production in June.

Federation removes this whole category instead of babysitting it. There's no standing secret in the CI platform, none in AWS, and none anywhere in between once a job finishes. To abuse the pipeline's access, an attacker now has to compromise a job while it's actually running and act inside the credential's short life. And every credential AWS hands out is stamped in the audit log with the exact repository, branch, or environment that asked for it, so a request from somewhere odd stands out.

What happens during the handshake

Your CI platform runs an OIDC issuer, which you can picture as a passport office. It does two jobs: it prints identity documents on request, and it publishes the official stamps so anyone can check a document is genuine. When a job starts, it asks the office for a JWT (JSON Web Token, a small signed JSON document). The fields inside are called claims, and they describe the job: iss (the issuer's URL), sub (the subject, meaning which repo, branch, or environment is running), aud (the audience, meaning who the token is for), and an expiry a few minutes out. For a GitHub Actions job deploying the production environment of acme/payments-api, the sub claim is the literal string repo:acme/payments-api:environment:production.

The OIDC token exchange, start to finish
1Job starts
The CI runner asks its own OIDC issuer for an identity token
2Issuer mints a JWT
Signed claims: iss, sub, aud, and an expiry a few minutes out
3Job calls STS
Calls the AWS Security Token Service (sts:AssumeRoleWithWebIdentity), handing over the JWT
4STS checks the signature
Pulls the issuer's public keys over HTTPS, then verifies the signature and the expiry
5STS reads the trust policy
Compares the sub and aud claims against the role's conditions
6Match: credentials issued
Temporary access key, secret key, and session token, scoped to that one role
7Job ends, nothing persists
The temporary credentials lapse at the session cap; no secret is written anywhere to steal later
No AWS secret exists before the job starts or after the credentials expire. There is never anything at rest to steal.

On the AWS side you register the issuer once per account as an IAM identity provider (a standing note in your account that says 'I trust tokens signed by this issuer'). When the job calls sts:AssumeRoleWithWebIdentity and presents its JWT, STS (the part of AWS that hands out temporary credentials) does the passport check. It fetches the issuer's public stamps from a published JWKS (JSON Web Key Set, the list of public verification keys) endpoint, confirms the signature is real and the token hasn't expired, then reads the target role's trust policy (the JSON document that says who is allowed to assume the role) and compares the token's claims against the policy's conditions. Only if every condition matches does STS return a temporary access key, secret key, and session token that all die when the session ends.

Register the issuer, once per account

You teach the account to trust GitHub's issuer exactly once. After that, every role in the account can point at it.

terminal
# Register GitHub's OIDC issuer as an identity provider (once per account).
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
output
{
"OpenIDConnectProviderArn": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
}

That thumbprint is mostly a fossil now. Since 2023, AWS secures its connection to this issuer using a library of trusted certificate authorities, so it no longer leans on the fingerprint you paste in. Leave --thumbprint-list off and IAM fetches one for you; the well-known value above still works if you'd rather pin it. Either way, the thumbprint is not the thing keeping you safe.

The trust policy is the whole lock

Every bit of security now lives in one JSON file: the role's trust policy. The aud condition throws out any token minted for a different consumer. The sub condition is the real gate, nailing the role to one repository and one environment. Anything an attacker might get their hands on, a fork, a feature branch, a different repo in your org, produces a different sub and fails the match. And sub isn't the only claim you can pin: you can also add conditions on the job's ref, its environment, or job_workflow_ref (the exact reusable workflow file a job called) to tighten the match to precisely the pipeline you trust.

trust.json
{
"Version": "2012-10-17",
"Statement": [{
"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",
"token.actions.githubusercontent.com:sub": "repo:acme/payments-api:environment:production"
}
}
}]
}

Two locks, not one. The trust policy is the door: it decides who may assume the role. A separate permissions policy is what's inside the room: it decides what a session of that role can actually touch once the door opens. Getting in and being able to do damage are different questions, and you set them in different files. The create-role call sets the door with a tight session cap; attach-role-policy hangs a least-privilege deploy policy (only the exact actions this pipeline needs, nothing spare) inside. Never AdministratorAccess.

terminal
aws iam create-role --role-name gha-payments-deploy \
--assume-role-policy-document file://trust.json \
--max-session-duration 3600
# attach-role-policy prints nothing on success
aws iam attach-role-policy --role-name gha-payments-deploy \
--policy-arn arn:aws:iam::111122223333:policy/payments-deploy-scoped
output
{
"Role": {
"Path": "/",
"RoleName": "gha-payments-deploy",
"RoleId": "AROAEXAMPLE7EXAMPLE",
"Arn": "arn:aws:iam::111122223333:role/gha-payments-deploy",
"CreateDate": "2026-07-22T09:14:03+00:00",
"AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%20...%20%7D",
"MaxSessionDuration": 3600
}
}
The trust policy lets you in; it does not limit what you can do
A perfectly pinned sub still hands out a fully-privileged session if the permissions policy is wide open. Attach AdministratorAccess to a federated deploy role and the day someone loosens the sub (or an attacker lands a job that matches it) they own the account, not only the deploy. Scope the permissions policy to the exact actions and resources this pipeline needs, and keep the trust policy narrow. Both locks, every time.

Wire the workflow (GitHub Actions)

The job needs one new permission, id-token: write, which lets it ask GitHub for an OIDC token. The official credentials action runs the whole exchange for you. Read the file for what's missing: no aws-access-key-id, no secret stashed in the repo settings, nothing to leak.

.github/workflows/deploy.yml
permissions:
id-token: write # lets the job request an OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-payments-deploy
role-session-name: deploy-${{ github.run_id }}
aws-region: eu-west-1
- run: aws sts get-caller-identity
output
{
"UserId": "AROAEXAMPLE7EXAMPLE:deploy-9174231998",
"Account": "111122223333",
"Arn": "arn:aws:sts::111122223333:assumed-role/gha-payments-deploy/deploy-9174231998"
}
A wildcard subject hands your role to strangers' branches
The most common trust-policy mistake is a wildcard: StringLike with repo:acme/payments-api:* or, worse, repo:acme/*. That pattern matches every branch and every pull_request subject, so any contributor's throwaway branch can reach your production role. In pull_request_target workflows, where the job runs with your repository's own secrets, that can even be code written by an outside contributor. Pin sub with StringEquals to the exact refs or environments you trust, and review every trust-policy change as carefully as you review production code, because it is production code.

Want proof the lock did what you think? Every AssumeRoleWithWebIdentity call lands in CloudTrail (the running log AWS keeps of the API calls made against your account, an API call being any request your tools or pipelines send to AWS), and the record carries the exact subject the token presented. Pull the last one and read it back.

terminal
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
--max-results 1 --query 'Events[0].CloudTrailEvent' --output text \
| jq '.responseElements | {subjectFromWebIdentityToken, audience}'
output
{
"subjectFromWebIdentityToken": "repo:acme/payments-api:environment:production",
"audience": "sts.amazonaws.com"
}

That subject should be the exact string you pinned in the trust policy. A sub you don't recognize assuming a deploy role is a page-someone-at-2am event, not a shrug.

GitLab, same idea, different claims

GitLab plays the same game with a different scoreboard. The issuer is your GitLab URL (https://gitlab.com for the hosted service, or your own instance's address). Instead of an ambient token, you name the token you want with the id_tokens keyword and hand it to the assume-role call yourself. You register a second IAM identity provider for gitlab.com and a role whose trust policy uses the gitlab.com:sub and gitlab.com:aud condition keys.

.gitlab-ci.yml
deploy:
image: amazon/aws-cli:2.17.0
id_tokens:
AWS_TOKEN: # GitLab mints this JWT for the job
aud: sts.amazonaws.com
script:
# $AWS_ROLE_ARN is a plain (non-secret) CI/CD variable
- >
creds=$(aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_ARN"
--role-session-name "gitlab-$CI_PIPELINE_ID"
--web-identity-token "$AWS_TOKEN"
--query 'Credentials' --output json)
- export AWS_ACCESS_KEY_ID=$(echo "$creds" | jq -r .AccessKeyId)
- export AWS_SECRET_ACCESS_KEY=$(echo "$creds" | jq -r .SecretAccessKey)
- export AWS_SESSION_TOKEN=$(echo "$creds" | jq -r .SessionToken)
- aws sts get-caller-identity

The only real difference is the sub string. GitLab's looks like project_path:acme/payments-api:ref_type:branch:ref:main, so the trust policy's sub condition matches that shape instead of GitHub's. Same lock, different key cut. Notice again there's no stored AWS key anywhere in the project; GitLab hands the job a fresh JWT, and AWS trades it for a session that expires on its own.

Harden it, and know the honest limits

Four habits keep this safe as it spreads. One role per repository per environment, never a shared deploy role, so any single compromise stays one pipeline wide. Cut --max-session-duration down to your longest real deploy instead of the default hour. On GitHub, customize the sub claim to fold in repository_id, because subjects are name-based by default and a deleted org or repo name can be re-registered by a stranger who then satisfies your trust policy word for word. And read CloudTrail: an unfamiliar sub assuming a deploy role should wake someone up.

One quiet trap: reusable workflows. When a job calls a shared workflow, the sub can point at the caller rather than the shared file, so if the reusable workflow is the thing you actually trust, pin job_workflow_ref in the trust policy instead of (or alongside) sub.

The costs are operational, not cryptographic. The provider and roles have to exist in every account before the first pipeline runs, so past ten accounts you stamp them out with infrastructure-as-code, never by hand. STS has to reach the issuer's JWKS endpoint over the public internet, which rules out a self-hosted GitLab that only resolves inside your VPN (virtual private network). Credentials can't outlive the session cap, and that cap itself tops out at twelve hours, so a job that runs longer than that needs a different pattern. And the failures are quiet: STS answers any claim mismatch with the same flat Not authorized to perform sts:AssumeRoleWithWebIdentity, so debugging means decoding the JWT your job actually received and diffing its claims against the trust policy, one line at a time.

A trust policy is a guardrail on one role in one account, and it's only ever as good as the person who wrote it. The next layer assumes someone eventually writes a bad one. AWS Organizations and service control policies set a ceiling above every role in every account, a limit that even a perfectly federated, fully privileged session cannot punch through.

Quick check
01Why does OIDC federation remove the whole class of 'leaked CI key' risk, rather than only reduce it?
Incorrect — there is no stored access key to encrypt; that's the old model federation replaces.
Correct — the only credential is a short-lived session tied to one job, so there's nothing at rest to leak.
Incorrect — the OIDC token lives for minutes, and it isn't the AWS credential anyway.
Incorrect — no AWS key is stored in GitHub at all; that's exactly the risk federation eliminates.
02GitHub subjects are name-based by default. Why do the docs recommend customizing the sub claim to include repository_id?
Incorrect — this is about identity stability, not token size or verification speed.
Incorrect — aud is set independently (to sts.amazonaws.com) and has nothing to do with repository_id.
Correct — names can be reclaimed after deletion, but the immutable numeric id closes that impersonation gap.
Incorrect — the trust policy is still the lock; repository_id just makes its sub condition harder to spoof.
03A GitHub Actions job fails with Not authorized to perform sts:AssumeRoleWithWebIdentity. It's running on a feature branch, and your trust policy pins sub with StringEquals to repo:acme/payments-api:environment:production. Most likely cause and fix?
Incorrect — the thumbprint is vestigial since 2023 and never produces this claim-mismatch error.
Incorrect — this is an authorization failure, not a rate-limit; a retry changes nothing.
Incorrect — a missing permission fails earlier, when requesting the token, with a different error (no token URL), not an STS not-authorized.
Correct — a branch run produces a sub the StringEquals condition can't match, and the opaque error is exactly this claim mismatch.

Related