CI/CD

Not authorized to perform sts:AssumeRoleWithWebIdentity

The OIDC trust policy rejected the CI job's token. How to read the claims your provider actually sends and match the condition to them exactly.

hard fix5 min read

the ci/cd error
Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity

An error occurred (InvalidIdentityToken) when calling the AssumeRoleWithWebIdentity operation: Incorrect token audience

An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity

Do this first3 steps

Run these in order. Each one tells you what its output means before you change anything.

  1. 1

    Print the claims your CI actually sends

    echo 'decode the JWT payload in a job step: cut -d. -f2 of the token, base64 -d'

    The sub claim is what the trust policy condition must match, character for character. Guessing its format is the cause of most of these failures, and the repo/branch encoding differs between pull requests, branches, tags and environments.

  2. 2

    Read the role's current trust policy

    aws iam get-role --role-name gha-deploy --query 'Role.AssumeRolePolicyDocument' | python3 -m json.tool

    Compare the Condition block against the claims. A StringEquals on sub that does not match exactly fails, and a wildcard needs StringLike rather than StringEquals.

  3. 3

    Confirm the OIDC provider exists in the account

    aws iam list-open-id-connect-providers

    Without a provider for token.actions.githubusercontent.com or gitlab.com, AWS has nothing to validate the token against and rejects it before the trust policy is even evaluated.

All 8 sections

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.

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:

Triggersub
Push to a branchrepo:acme/myapp:ref:refs/heads/main
Tagrepo:acme/myapp:ref:refs/tags/v1.4.2
Pull requestrepo:acme/myapp:pull_request
Environmentrepo: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

  1. Decode the token in a job and read the real sub. Do not guess it.
  2. GitHub Actions → permissions: id-token: write and contents: read.
  3. aws iam get-role --query 'Role.AssumeRolePolicyDocument' and compare.
  4. Wildcards need StringLike, not StringEquals.
  5. Always condition on sub. An aud-only policy trusts every repository on the platform.
  6. Incorrect token audience → the aud the job requests must match the policy.
  7. aws iam list-open-id-connect-providers. One per account.
  8. 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.

Reference and practice

Learn the underlying concept

Other CI/CD errors