All posts
Security

How GitHub Actions OIDC to AWS Actually Works (and the Eight Ways It Breaks)

AssumeRoleWithWebIdentity returns AccessDenied. The OIDC token looks valid. The trust policy looks right. The error message is useless. Eight specific causes, eight specific fixes, and a diagnostic that finds the right one in 30 seconds.

By Sharon Sahadevan··10 min read

You finally finished migrating your CI from stored AWS access keys to OIDC federation. Production deploys worked all week. You add one new workflow that needs the same role. It fails:

Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity

You read the trust policy. It looks fine. You check the workflow's permissions. They look fine. You compare against the working workflow. They look almost identical. The only difference is one parameter you cannot quite identify, and the error message tells you nothing.

This is the GitHub Actions OIDC tax. The migration from long-lived AWS access keys to OIDC federation is the right move, and AWS plus GitHub make it harder than it should be. The error messages are deliberately vague (security best practice; reveal nothing to potential attackers) and the dozen-or-so things that can be wrong all produce the same AccessDenied.

This post is the full mechanism (what each side actually does), the eight specific causes of that AccessDenied, the diagnostic that names the right one fast, and the trust-policy patterns that scale to dozens of workflows.

What actually happens during OIDC federation#

The flow has eight steps:

1. Workflow run starts. GitHub mints an OIDC token (a JWT) for this
   specific run, signed by GitHub's OIDC issuer.

2. The token's claims include: 
     iss = https://token.actions.githubusercontent.com
     aud = sts.amazonaws.com (configurable)
     sub = repo:org/repo:ref:refs/heads/main
       (or repo:org/repo:environment:production
        or repo:org/repo:pull_request, etc.)
     job_workflow_ref, run_id, actor, etc.

3. The aws-actions/configure-aws-credentials action reads two env vars:
     ACTIONS_ID_TOKEN_REQUEST_TOKEN
     ACTIONS_ID_TOKEN_REQUEST_URL
   These are present only if the workflow has 'id-token: write' permission.

4. The action fetches the OIDC token from GitHub's API using those creds.

5. The action calls AWS STS AssumeRoleWithWebIdentity, passing:
     RoleArn     (the IAM role to assume)
     WebIdentityToken   (the OIDC token from GitHub)
     RoleSessionName

6. STS validates the OIDC token:
     - Signature verifies against GitHub's published JWKS.
     - The IAM OIDC provider is configured for token.actions.githubusercontent.com.
     - The thumbprint of GitHub's TLS cert matches what the OIDC provider has registered.

7. STS evaluates the IAM role's trust policy against the token's claims.
   If any condition fails, the assume call fails.

8. If everything passes, STS returns short-lived AWS credentials.
   The action writes them to the workflow's environment.

Eight steps. The error message is the same regardless of which one failed. The diagnostic skill is figuring out which step.

The eight ways it breaks#

Each cause maps to one of those eight steps.

Cause 1: missing id-token: write permission#

The workflow does not have permission to mint OIDC tokens. The aws-actions step gets no token to send.

# Symptom: error before any STS call
# "Unable to retrieve OIDC token"

# Fix: add the permission at workflow or job level
permissions:
  id-token: write
  contents: read

This is the #1 cause for first-time OIDC setups. The default permissions for workflows do not include id-token: write. You have to opt in explicitly.

Cause 2: OIDC provider not configured in AWS#

AWS does not yet trust GitHub's OIDC issuer. STS rejects the AssumeRoleWithWebIdentity call before even looking at the trust policy.

# Check whether the OIDC provider exists
aws iam list-open-id-connect-providers
# Should include an entry for token.actions.githubusercontent.com

# If missing, create it
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
  --client-id-list sts.amazonaws.com

One-time setup per AWS account. If you are running across many accounts, this has to be done in each.

Cause 3: thumbprint mismatch#

The thumbprint is the SHA-1 of GitHub's TLS certificate. It pins trust at the network layer. When GitHub rotates that cert (which happened in 2023 and will happen again), every AWS account that has the old thumbprint stops accepting tokens.

# Get GitHub's current thumbprint
echo | openssl s_client -servername token.actions.githubusercontent.com \
    -connect token.actions.githubusercontent.com:443 2>/dev/null | \
    openssl x509 -fingerprint -noout -sha1 | \
    sed 's/SHA1 Fingerprint=//' | tr -d ':' | tr 'A-Z' 'a-z'

# Compare to what AWS has
aws iam get-open-id-connect-provider \
  --open-id-connect-provider-arn arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com \
  | jq .ThumbprintList

# If they differ, update
aws iam update-open-id-connect-provider-thumbprint \
  --open-id-connect-provider-arn arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com \
  --thumbprint-list <new-thumbprint>

Note: as of late 2023, AWS started auto-validating against GitHub's OIDC issuer's known root CAs even when thumbprints mismatch, so this is less catastrophic than it once was. But explicit thumbprint pinning is still the safer pattern.

Cause 4: trust policy sub condition does not match#

The most common cause once basic setup is done. The sub claim in the OIDC token is built by GitHub from the repo, the ref, and the trigger. Trust policy conditions are typed string matches; one character off and the assume fails.

// Trust policy with a strict sub match
{
  "Effect": "Allow",
  "Principal": {"Federated": "arn:aws:iam::123: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:mycompany/myrepo:ref:refs/heads/main"
    }
  }
}

The sub claim format varies by trigger:

Triggersub claim
push to mainrepo:org/repo:ref:refs/heads/main
push to feature branchrepo:org/repo:ref:refs/heads/feature/x
pull requestrepo:org/repo:pull_request
environment "production"repo:org/repo:environment:production
tag v1.2.3repo:org/repo:ref:refs/tags/v1.2.3
reusable workflowrepo:org/repo:workflow:.github/workflows/deploy.yml@refs/heads/main

If you use StringEquals with one specific value, only that exact trigger works. For "main branch OR production environment OR any tag," you need:

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  },
  "StringLike": {
    "token.actions.githubusercontent.com:sub": [
      "repo:mycompany/myrepo:ref:refs/heads/main",
      "repo:mycompany/myrepo:environment:production",
      "repo:mycompany/myrepo:ref:refs/tags/v*"
    ]
  }
}

Cause 5: audience claim mismatch#

The default audience GitHub puts in the OIDC token is sts.amazonaws.com. If you customize it (some setups do, especially when sharing tokens across systems) and forget to update the trust policy, the audience check fails.

# Workflow: customize the audience
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123:role/deploy
    audience: my-custom-audience    # non-default
    aws-region: us-east-1

Trust policy must match:

"StringEquals": {
  "token.actions.githubusercontent.com:aud": "my-custom-audience"
}

Do not customize the audience unless you have a reason. The default works for the standard case.

Cause 6: trust policy too permissive (an audit finding, not a runtime bug)#

Common pattern that "works" but is dangerous:

// BAD: any workflow on any repo can assume this role
"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  }
}

Without a sub condition, this trust policy accepts OIDC tokens from any repo on GitHub. An attacker creates a public repo, runs a workflow, assumes your IAM role. AWS auditors flag this; they should.

Fix: always add a sub condition that restricts to your specific org and repo.

Cause 7: branch / environment is not what you think#

The workflow runs on a feature branch, but the trust policy expects main. The error is the same AccessDenied. Common during rollout: testing in a feature branch but the trust policy was written for main only.

Diagnostic: print the OIDC token claims in the workflow:

- name: Debug OIDC token
  run: |
    OIDC_TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
    
    # Decode claims (header.payload.signature)
    echo "$OIDC_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
    # Look at: sub, aud, ref, environment

Run this in your failing workflow once. The output tells you exactly what claims AWS is seeing. Compare to your trust policy.

Cause 8: organization or environment protection blocking the workflow#

GitHub Environments can have protection rules: required reviewers, branch restrictions. If the workflow uses environment: production and is on a non-allowed branch, GitHub blocks the workflow before it ever requests an OIDC token. This shows up as a different error (the workflow waits for approval), but teams sometimes confuse this with the OIDC AccessDenied.

jobs:
  deploy:
    environment: production   # may require approval; may restrict to main
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123:role/deploy-prod
          aws-region: us-east-1

If the deploy is "stuck waiting for approval," it is the environment protection, not OIDC.

The diagnostic in 30 seconds#

Faced with AccessDenied:

Step 1: print the OIDC token claims. The debug snippet above. This shows you exactly what GitHub is sending.

Step 2: get the trust policy.

aws iam get-role --role-name deploy-prod --query Role.AssumeRolePolicyDocument

Step 3: compare. The token's claims must satisfy every condition in the trust policy.

Token: aud=sts.amazonaws.com, sub=repo:mycompany/myrepo:ref:refs/heads/feature
Trust: aud=sts.amazonaws.com, sub=repo:mycompany/myrepo:ref:refs/heads/main

Mismatch: token sub is "feature" branch; trust requires "main".

The first cause-by-claim mismatch is the bug. Almost always.

The trust-policy pattern that scales#

Once you have more than a handful of workflows assuming roles, repeating the same trust policy gets tedious and the variations introduce bugs. The clean pattern: per-repo or per-environment roles with focused trust policies, plus a script that generates them.

# Terraform module for a per-repo deploy role
variable "repo" { type = string }              # e.g., "mycompany/myrepo"
variable "environments" { type = list(string)} # e.g., ["staging", "production"]

resource "aws_iam_role" "deploy" {
  name = "deploy-${replace(var.repo, "/", "-")}"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = "arn:aws:iam::${var.account_id}: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" = [
            for env in var.environments : "repo:${var.repo}:environment:${env}"
          ]
        }
      }
    }]
  })
}

Per-repo, per-environment scope. New repo or new environment is a small Terraform diff. Over-broad trust policies stop being attractive.

What actually causes the AccessDenied most of the time#

In rough frequency:

  1. Missing id-token: write permission (50% of first-time setups).
  2. Trust policy sub condition does not match the actual claim (30% of mid-tier issues).
  3. Trust policy too permissive (audit finding) (10% of mature setups).
  4. OIDC provider not configured (5%, mostly new accounts).
  5. Thumbprint mismatch after rotation (5%, episodic).

The remaining causes happen but rarely. If you debug a new setup, check 1 first; if you debug an existing-working-setup that broke, check 5.

Quick reference: the OIDC AccessDenied checklist#

1. Check workflow permissions:
   permissions: id-token: write

2. Print the OIDC token claims in the failing workflow:
   curl with $ACTIONS_ID_TOKEN_REQUEST_TOKEN; decode .value as JWT.
   Note: aud, sub, ref, environment.

3. Get the trust policy:
   aws iam get-role --role-name $ROLE --query Role.AssumeRolePolicyDocument

4. Compare claims to conditions:
   - aud claim matches StringEquals aud condition?
   - sub claim matches StringEquals or StringLike sub condition?
   - Federated principal matches the OIDC provider ARN in your account?

5. If still stuck:
   - aws iam list-open-id-connect-providers (does the provider exist?)
   - Compare current GitHub TLS thumbprint to AWS-stored thumbprint

6. Once working: tighten trust policy if too broad.

Operational follow-ups worth doing once#

After OIDC is working, two things worth setting up:

1. Monitor for AccessDenied on AssumeRoleWithWebIdentity in CloudTrail. Spike in failures means a config change broke something.

CloudTrail filter:
  eventName = "AssumeRoleWithWebIdentity"
  AND errorCode IS NOT NULL

2. Audit trust policies periodically. Look for:

  • Trust policies without a sub condition (over-broad).
  • Trust policies with stale repo paths (renamed repos).
  • Trust policies referencing repos that no longer exist.

A monthly audit catches drift.

The mental model#

OIDC federation between GitHub and AWS is two trust decisions composed:

  1. AWS trusts GitHub as an identity provider (via the OIDC provider config + thumbprint).
  2. AWS trusts specific GitHub workflows to assume specific IAM roles (via the trust policy conditions on sub, aud, etc.).

Either decision can fail and produces the same AccessDenied. The diagnostic is naming which decision failed. Once you have the model, the eight-cause checklist becomes a two-minute exercise instead of a two-hour mystery.


OIDC federation, IAM trust policies, JWT validation, and the broader workforce-identity stack are covered in depth in the Identity and Trust for DevOps Engineers course. For the Kubernetes-side equivalent (kubectl OIDC, IRSA, K8s API server OIDC config) see the Kubernetes Security course.

More in Security