OIDC federation lets a CI job exchange a short-lived identity token for an AWS role session, with no stored access keys. When it fails, one of three things is wrong: the provider is missing, the audience does not match, or the trust policy condition does not match the token's claims.
The third is almost always it.
Print the claims
Stop guessing at the sub format. Decode the token in a job:
# GitHub Actions
permissions:
id-token: write
contents: read
steps:
- name: Show OIDC claims
run: |
TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
{
"sub": "repo:acme/myapp:ref:refs/heads/main",
"aud": "sts.amazonaws.com",
"repository": "acme/myapp",
"ref": "refs/heads/main"
}
That sub is what your condition must match. The formats differ by trigger:
| Trigger | sub |
|---|---|
| Push to a branch | repo:acme/myapp:ref:refs/heads/main |
| Tag | repo:acme/myapp:ref:refs/tags/v1.4.2 |
| Pull request | repo:acme/myapp:pull_request |
| Environment | repo:acme/myapp:environment:production |
A trust policy written for a branch push rejects a pull request, which is usually the intent and occasionally a surprise.
GitLab's is different again:
project_path:acme/myapp:ref_type:branch:ref:main
The permissions block
permissions:
id-token: write
contents: read
Without id-token: write the job gets no token at all, and the action fails before reaching AWS. This is the most common single cause in GitHub Actions, and contents: read must be listed too, because declaring the block sets everything unlisted to none.
The trust policy
{
"Version": "2012-10-17",
"Statement": [{
"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:acme/myapp:ref:refs/heads/*"
}
}
}]
}
aws iam get-role --role-name gha-deploy \
--query 'Role.AssumeRolePolicyDocument' | python3 -m json.tool
Three things to get right:
StringLike for wildcards. StringEquals with a * in the value matches a literal asterisk and nothing else.
Always condition on sub. A policy with only the aud condition trusts every GitHub repository in the world, because every one of them presents sts.amazonaws.com as the audience. That is a serious misconfiguration and it is easy to arrive at while debugging.
Do not over-wildcard. repo:acme/* trusts every repository in your organisation. Scope to the repository, and to the branch or environment where you can.
Audience
InvalidIdentityToken: Incorrect token audience
The token's aud and the policy's aud condition must match. aws-actions/configure-aws-credentials requests sts.amazonaws.com by default. GitLab defaults to the project URL, so you set it explicitly:
# GitLab CI
deploy:
id_tokens:
AWS_TOKEN:
aud: https://gitlab.com
script:
- >
aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_ARN"
--role-session-name "gitlab-$CI_JOB_ID"
--web-identity-token "$AWS_TOKEN"
--duration-seconds 3600
Whatever you put in aud must be what the trust policy expects.
The provider must exist
aws iam list-open-id-connect-providers
{ "OpenIDConnectProviderList": [
{ "Arn": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }
]}
One per account, not per role. Create it if absent:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
AWS stopped requiring a thumbprint for well-known providers, so older guides telling you to fetch one by hand are out of date. A stale hardcoded thumbprint in Terraform is itself a cause of sudden failures when the certificate rotates.
Session duration
DurationSeconds exceeds the MaxSessionDuration set for this role
The role's MaxSessionDuration caps what a job may request:
aws iam update-role --role-name gha-deploy --max-session-duration 3600
Keep it short. A CI role session should last a job, not a day.
A checklist
- Decode the token in a job and read the real
sub. Do not guess it. - GitHub Actions →
permissions: id-token: writeandcontents: read. aws iam get-role --query 'Role.AssumeRolePolicyDocument'and compare.- Wildcards need
StringLike, notStringEquals. - Always condition on
sub. Anaud-only policy trusts every repository on the platform. Incorrect token audience→ theaudthe job requests must match the policy.aws iam list-open-id-connect-providers. One per account.- Remove hardcoded thumbprints; they expire and are no longer required.
Frequently Asked Questions
Why does the role assumption fail when the trust policy looks correct?
Almost always because the sub claim is not what you assumed. Its format varies by trigger: a branch push sends repo:owner/name:ref:refs/heads/main, a tag sends refs/tags/..., a pull request sends repo:owner/name:pull_request, and an environment deployment sends repo:owner/name:environment:production. A policy written for one rejects the others. Decode the token inside a job and print the payload rather than inferring the format from documentation.
What does "id-token: write" do?
It permits the job to request an OIDC identity token from GitHub's token service. Without it, no token is issued and the AWS action fails before it reaches AWS at all. The subtlety that catches people is that adding a permissions block sets every permission you do not list to none, so a block containing only id-token: write removes the job's ability to check out the repository. Include contents: read alongside it.
Is it safe to condition only on the audience?
No, and it is a serious misconfiguration. Every GitHub Actions job on the platform presents sts.amazonaws.com as its audience when configured for AWS, so a trust policy conditioning only on aud allows any repository anywhere to assume your role. It is easy to arrive at while debugging, because removing the sub condition makes the error go away. Always condition on sub, scoped to your repository and ideally to a specific branch or environment.
Do I still need an OIDC thumbprint?
No. AWS now validates well-known OIDC providers against trusted certificate authorities, so the thumbprint is no longer required when creating the provider. Older guides and Terraform modules that hardcode one are a liability rather than a safeguard: when the provider rotates its certificate, the stale thumbprint causes sudden, confusing authentication failures across every workflow. If you have one pinned, removing it is a worthwhile change.
Why use OIDC instead of access keys in CI?
Because there is no long-lived credential to store, rotate or leak. The platform issues a signed token for each job, AWS exchanges it for a session that expires when the job ends, and nothing durable exists in your secrets store. It also gives much finer control: the trust policy can restrict a role to one repository and one branch, so a workflow on a feature branch cannot assume the production deployment role even though it runs in the same repository.