OIDC federation from CI
GitHub/GitLab to AWS with zero stored keys.
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.
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.
# 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
{"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.
{"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.
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 successaws iam attach-role-policy --role-name gha-payments-deploy \--policy-arn arn:aws:iam::111122223333:policy/payments-deploy-scoped
{"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}}
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.
permissions:id-token: write # lets the job request an OIDC tokencontents: readjobs:deploy:runs-on: ubuntu-latestenvironment: productionsteps:- uses: aws-actions/configure-aws-credentials@v4with:role-to-assume: arn:aws:iam::111122223333:role/gha-payments-deployrole-session-name: deploy-${{ github.run_id }}aws-region: eu-west-1- run: aws sts get-caller-identity
{"UserId": "AROAEXAMPLE7EXAMPLE:deploy-9174231998","Account": "111122223333","Arn": "arn:aws:sts::111122223333:assumed-role/gha-payments-deploy/deploy-9174231998"}
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.
aws cloudtrail lookup-events \--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \--max-results 1 --query 'Events[0].CloudTrailEvent' --output text \| jq '.responseElements | {subjectFromWebIdentityToken, audience}'
{"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.
deploy:image: amazon/aws-cli:2.17.0id_tokens:AWS_TOKEN: # GitLab mints this JWT for the jobaud: sts.amazonaws.comscript:# $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.
sub claim to include repository_id?aud is set independently (to sts.amazonaws.com) and has nothing to do with repository_id.repository_id just makes its sub condition harder to spoof.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?sub the StringEquals condition can't match, and the opaque error is exactly this claim mismatch.