SE
STACKENGINE
Zero-Trust Client IDE/ Security

GitHub Actions OIDC AssumeRole Permission Audit

GitHub Actions OIDC AssumeRole fails with AccessDenied due to misconfigured trust policy conditions or missing identity provider in the target AWS account.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(1L)
AI Audit Console
> Waiting for pre-flight audit...
AirLock HYBRID KERNEL: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPECUpdated 2026

Fixing GitHub Actions OIDC AssumeRole AccessDenied: Cross-Account Trust Policy Debugging Guide

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15–30 mins


TL;DR

  • What broke: GitHub Actions cannot call sts:AssumeRoleWithWebIdentity because the IAM role's trust policy in the target account rejects the OIDC token — wrong sub condition, missing OIDC provider registration, or incorrect aud claim.
  • How to fix it: Register token.actions.githubusercontent.com as an OIDC Identity Provider in every target account, then lock down the trust policy with exact sub and aud condition keys matching your repo and workflow.
  • Shortcut: Use our Client-Side Sandbox above to auto-refactor this — paste your trust policy and workflow YAML, and get a corrected policy without sending your ARNs to a third-party server.

The Incident (What Does the Error Mean?)

Raw error from GitHub Actions runner or AWS CLI:

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

Or from the AWS STS service itself:

ErrorCode: AccessDenied
Message:   Not authorized to perform sts:AssumeRoleWithWebIdentity
RequestId: a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Immediate consequence: Your deployment pipeline is dead. Every job that depends on AWS credentials — ECR push, S3 sync, EKS deploy, Terraform apply — fails at the credential acquisition step. No fallback. No retry logic saves you here.


The Attack Vector / Blast Radius

This isn't just a broken pipeline — an incorrectly permissive trust policy is an active privilege escalation vector.

Scenario A — Too Restrictive (your current outage): The sub condition in the trust policy doesn't match the token claim. GitHub sends repo:org/repo:ref:refs/heads/main; your policy has repo:org/repo:environment:production. STS rejects it. Pipeline down.

Scenario B — Too Permissive (the real danger you must avoid while fixing this): If you "fix" the outage by removing the Condition block entirely or using StringLike with a wildcard like repo:org/*:*, any GitHub Actions workflow in your entire org can assume this role. An attacker who compromises any repo in your org — even a throwaway repo — can pivot into your AWS account with whatever permissions this role holds.

Cross-account blast radius: In a cross-account setup, the target account role is typically granted elevated permissions (e.g., AdministratorAccess or broad deployment permissions). A misconfigured trust policy here means a compromised GitHub token can traverse account boundaries and hit production infrastructure directly.

The specific failure points to audit in order:

  1. OIDC provider token.actions.githubusercontent.com not registered in the target account
  2. Principal.Federated ARN points to the wrong account's OIDC provider
  3. Condition StringEquals on token.actions.githubusercontent.com:sub doesn't match actual sub claim
  4. Condition StringEquals on token.actions.githubusercontent.com:aud is missing or set to something other than sts.amazonaws.com
  5. Missing sts:AssumeRoleWithWebIdentity in the trust policy Action

How to Fix It (The Solution)

Step 0 — Verify the actual `sub` claim in the token

Before touching IAM, decode the actual OIDC token GitHub is sending. Add this debug step to your workflow temporarily:

- name: Debug OIDC Token Claims
  run: |
    TOKEN=$(curl -sSfL -H "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 .

The sub field output is the exact string your trust policy condition must match.


Basic Fix — Register OIDC Provider and Correct Trust Policy

Register the OIDC provider in the target account (do this 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 \
  --region us-east-1

Corrected trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
-       "Federated": "arn:aws:iam::SOURCE_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
+       "Federated": "arn:aws:iam::TARGET_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
-     "Action": "sts:AssumeRole",
+     "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
-         "token.actions.githubusercontent.com:aud": "https://github.com/your-org",
+         "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
-         "token.actions.githubusercontent.com:sub": "repo:your-org/*"
+         "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Enterprise Best Practice — Environment-Scoped, Least-Privilege Trust Policy

For production deployments, scope to a named GitHub Environment (not a branch ref). This forces the workflow to require environment protection rules (manual approval, required reviewers) before the token is issued with the matching sub.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
+       "Federated": "arn:aws:iam::TARGET_ACCOUNT_ID: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:your-org/your-repo:environment:production"
        },
+       "StringLike": {
+         "aws:RequestedRegion": "us-east-1"
+       }
      }
    }
  ]
}

GitHub Actions workflow — correct configuration:

jobs:
  deploy:
+   environment: production
    permissions:
-     id-token: none
+     id-token: write
+     contents: read
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
-         role-to-assume: arn:aws:iam::SOURCE_ACCOUNT_ID:role/DeployRole
+         role-to-assume: arn:aws:iam::TARGET_ACCOUNT_ID:role/DeployRole
+         role-session-name: GitHubActions-${{ github.run_id }}
          aws-region: us-east-1
+         audience: sts.amazonaws.com

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure browser-native utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Checkov — scan trust policies before terraform apply:

Checkov rule CKV_AWS_49 flags overly broad OIDC trust conditions. Add to your pipeline:

- name: Checkov IAM Scan
  uses: bridgecrewio/checkov-action@master
  with:
    directory: ./terraform
    check: CKV_AWS_49,CKV_AWS_274
    soft_fail: false

2. OPA/Conftest policy — block wildcard sub conditions:

package aws.iam.trust

deny[msg] {
  stmt := input.Statement[_]
  stmt.Action == "sts:AssumeRoleWithWebIdentity"
  cond := stmt.Condition.StringLike["token.actions.githubusercontent.com:sub"]
  endswith(cond, "*")
  not contains(cond, ":environment:")
  msg := sprintf("Trust policy sub condition '%v' is too broad. Scope to a named environment.", [cond])
}

3. Terraform — enforce OIDC provider existence as a data source (fail fast if missing):

data "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
  # This will hard-fail plan if the provider isn't registered in the target account
}

resource "aws_iam_role" "deploy" {
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = data.aws_iam_openid_connect_provider.github.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:environment:${var.environment}"
        }
      }
    }]
  })
}

4. Audit existing roles quarterly:

# Find all roles with GitHub OIDC trust and no sub condition (dangerous)
aws iam list-roles --query 'Roles[*].RoleName' --output text | tr '\t' '\n' | while read role; do
  policy=$(aws iam get-role --role-name "$role" --query 'Role.AssumeRolePolicyDocument' --output json 2>/dev/null)
  if echo "$policy" | grep -q 'token.actions.githubusercontent.com'; then
    if ! echo "$policy" | grep -q '"sub"'; then
      echo "DANGEROUS - No sub condition: $role"
    fi
  fi
done
Further Exploration / Matrix Routing