When AWS Authentication via GitHub Actions OIDC Failed, I Checked the sub Claim and Fixed the Trust Policy

When AWS Authentication via GitHub Actions OIDC Failed, I Checked the sub Claim and Fixed the Trust Policy

If OIDC authentication from GitHub Actions to AWS fails only for new repositories, GitHub's "immutable subject claim" change may be the cause. This article reproduces the error, checks the sub claim, and fixes the trust policy.
2026.10.10

This page has been translated by machine translation. View original

Hello, this is Tsukuboshi (tsukuboshi0755)!

When authenticating from GitHub Actions to AWS via OIDC, the standard approach was to write the sub condition in the IAM role's trust policy using the organization name and repository name.
However, for repositories created after July 15, 2026, if you keep using this standard notation, CI authentication to AWS will fail.
Have you ever been stuck, not knowing what to suspect when a configuration that works in existing repositories fails only in a new repository?

So in this article, I will reproduce the authentication error caused by the GitHub specification change responsible for this, the immutable subject claim, and walk through the steps of checking the sub claim and fixing the trust policy!

What is the immutable subject claim

The conventional sub contained the owner (organization or user) name and the repository name as they are.
This format has a weakness related to name reuse.
If a third party acquires the same name after a repository is deleted or renamed, the workflow of that repository can receive a token with the identical sub, and thus can assume the role on the AWS side.

https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/

To close this weakness, the new format now includes an immutable numeric ID following each name.
The main changes are as follows.

Item Details
Syntax of the new format repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH
Why @ is used as the delimiter Because @ cannot appear in GitHub usernames and repository names
Automatically applied to Repositories created after July 15, 2026. Renames and transfers after that date also switch to the new format
Existing repositories Switch to the new format only if you explicitly opt in
How to opt in Either at the organization level or the repository level, via the OIDC settings UI or the REST API

https://docs.github.com/ja/actions/reference/security/oidc

To prevent this scenario, repositories created on or after July 15, 2026 now use an immutable default subject format that includes both the owner ID and the repository ID.

Syntax: repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH

Unless you opt in to immutable subject claims, repositories created before July 15, 2026 keep the previous format. You can opt in at the organization or repository level by using the OIDC settings UI or the REST API.

For repositories created on or after July 15, 2026, or repositories that have opted in to immutable subject claims, the sub claim includes owner_id and repo_id, as shown in the immutable example. Update your trust policy to match the format used by the repository.

Existing repositories stay in the old format, so only newly created repositories receive the new-format sub, which makes it look like "only a specific repository fails."

Note that you cannot remove the numeric IDs from sub, and even if you customize claims with include_claim_keys in the REST API, owner_id and repo_id remain.
Therefore, the fix will be to align the trust policy side with the new format.

However, a trust policy aligned with the new format will not match existing repositories that send the old-format sub.
If you use the same template for existing repositories too, it is a good idea to decide first whether to opt in and align them to the new format.

Prerequisites

To reproduce the demo, prepare the following.

  • An AWS account with permissions to create IAM roles and OIDC providers with CloudFormation
  • A GitHub repository created after July 15, 2026 (hereafter referred to as octo-org/octo-repo)
  • The default branch of the above repository being main

Note that you cannot create multiple OIDC providers with the same URL in one AWS account.
In an account that already has a token.actions.githubusercontent.com provider, remove the GitHubOIDCProvider resource from the template below and rewrite it to reference the ARN of the existing provider.

This was verified with the following versions.

Item Version
aws-actions/configure-aws-credentials v6
GitHub Actions runner ubuntu-24.04

Trying it out

First, I will create a role with a conventional old-format trust policy and reproduce the authentication failure from the new repository.
Then I will check the actual sub and fix the trust policy to the new format.

Creating an IAM role with an old-format trust policy

I will create the OIDC provider and the IAM role together in a single CloudFormation template.
The sub in the trust policy is written in the old format that was common until now, with only the organization name and repository name.

bootstrap.yaml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  GitHubOrg:
    Type: String
    Default: octo-org

  RepositoryName:
    Type: String
    Default: octo-repo

Resources:
  GitHubOIDCProvider:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: https://token.actions.githubusercontent.com
      ClientIdList:
        - sts.amazonaws.com

  DemoRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: iamrole-for-oidc-demo
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Federated: !Ref GitHubOIDCProvider
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
                token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/${RepositoryName}:ref:refs/heads/main

Outputs:
  RoleArn:
    Value: !GetAtt DemoRole.Arn

No permission policy is attached to the role.
The sts get-caller-identity called for verification requires no permissions, so the trust policy alone is enough to check only whether authentication succeeds.

Deploy with the following command.
Replace GitHubOrg and RepositoryName with your own organization name and repository name.

aws cloudformation deploy \
  --template-file bootstrap.yaml \
  --stack-name github-oidc-demo \
  --parameter-overrides GitHubOrg=octo-org RepositoryName=octo-repo \
  --capabilities CAPABILITY_NAMED_IAM

After deployment, open the role's "Trust relationships" tab in the IAM console, and you can confirm that sub is registered in the old format.

Trust relationships tab with the old-format sub registered

The sub in the red frame is just the organization name and repository name separated by /, and contains no numeric IDs.

Creating the workflow

Add the following workflow to the repository.
For this verification, I have also included a diagnostic step, Debug OIDC claims, for checking the sub claim.
Specify the value of the stack output RoleArn for role-to-assume.

.github/workflows/oidc-demo.yml
name: OIDC demo

on:
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  assume-role:
    runs-on: ubuntu-24.04
    steps:
      - name: Configure AWS credentials
        id: aws-credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::<自アカウントID>:role/iamrole-for-oidc-demo
          aws-region: ap-northeast-1
          mask-aws-account-id: true

      - name: Debug OIDC claims
        if: failure() && steps.aws-credentials.outcome == 'failure' && runner.debug == '1'
        run: |
          TOKEN=$(curl -sS \
            -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" \
            | jq -r '.value')
          PAYLOAD=$(echo "${TOKEN}" | cut -d. -f2 | tr '_-' '/+')
          case $(( ${#PAYLOAD} % 4 )) in
            2) PAYLOAD="${PAYLOAD}==" ;;
            3) PAYLOAD="${PAYLOAD}=" ;;
          esac
          echo "${PAYLOAD}" | base64 -d | jq '{sub, aud, repository, ref}'

      - name: Get caller identity
        run: aws sts get-caller-identity

Debug OIDC claims is a diagnostic step included in this verification to check the actual sub when authentication fails.
Because the ID token is masked as soon as it is obtained, simply enabling ACTIONS_STEP_DEBUG will not make sub appear in the logs.
So the token is obtained with ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL, and only the payload part is decoded.

Since logs can be viewed by anyone who can read the repository, I included two measures in the diagnostic step to limit the information left in the logs.

  • A JWT can be used for authentication as is while it is within its validity period, so the whole token is not output; the payload extracted with cut -d. -f2 is narrowed down to only the necessary claims with jq
  • The diagnostic step is conditioned with if so that it runs only when authentication fails and it is a debug run (for reference, an id is attached to the authentication step)

Note that you should not leave the diagnostic step permanently in production workflows; add it temporarily when authentication fails, and remove it once the cause is found.

The final Get caller identity is a step to check whether the role could be assumed.
Its output includes the AWS account ID, so mask-aws-account-id: true is specified for Configure AWS credentials to mask it in the logs.

Reproducing the authentication error

Select the OIDC demo workflow from the repository's "Actions" tab and run it with "Run workflow".
Then the Configure AWS credentials step fails after repeated retries with the following error.

Retry AssumeRole: attempt 1 of 12 failed: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity. Retrying after 31ms.

Log of Configure AWS credentials that failed after retries

It is rejected with the same message from attempt 1 of 12, and the step ends with a failure display.

This error message is returned in the same way both when the trust policy conditions do not match and when the IdP ARN set on the role is wrong.
Therefore, the error message alone cannot narrow down the cause.

The main places to check when OIDC authentication fails are the following three.

What to check How to check
permissions of the workflow Whether id-token: write is set. Without it, the ID token itself cannot be obtained
aud condition of the trust policy Whether it matches the token's aud (the default for configure-aws-credentials is sts.amazonaws.com)
sub condition of the trust policy Whether it matches the token's sub: an exact match for StringEquals, a pattern match for StringLike

This workflow has permissions set, and aud is as in the template.
For the remaining sub, we cannot tell whether it matches without looking at what GitHub is actually sending.

Checking the sub claim with a debug run

From "Re-run jobs" at the top right of the failed run's page, select "Re-run all jobs", check "Enable debug logging", and re-run.

Re-run dialog with Enable debug logging checked

When you re-run with it checked, debug lines beginning with ##[debug] are added to each step's log.

In a debug run, runner.debug becomes 1, so the Debug OIDC claims step runs after the authentication failure.
If the new format applies, the output is as follows.

{
  "sub": "repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main",
  "aud": "sts.amazonaws.com",
  "repository": "octo-org/octo-repo",
  "ref": "refs/heads/main"
}

The sub claim output by the diagnostic step

repository remains organization name/repository name, and only sub has the numeric IDs added.

In contrast, what is registered in the trust policy's StringEquals is the old format with only the organization name and repository name.

repo:octo-org/octo-repo:ref:refs/heads/main

The token's sub has numbers prefixed with @ after the organization name and repository name, so it did not result in an exact match.
aud and the ref branch match the trust policy conditions, so we can conclude that the only remaining difference is sub.

Fixing the trust policy to the new format

To align the trust policy with the new format, you need to embed the organization ID and repository ID in the sub condition.
In my verification, I parameterized the two IDs with different handling.

bootstrap.yaml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  GitHubOrg:
    Type: String
    Default: octo-org

  GitHubOrgId:
    Type: String
    Description: 組織ID
    Default: "123456"
    AllowedPattern: ^[0-9]+$

  RepositoryName:
    Type: String
    Default: octo-repo

  RepositoryId:
    Type: String
    Description: リポジトリID。`*` を指定すると任意のリポジトリIDを受け付ける
    Default: "*"
    AllowedPattern: '^([0-9]+|\*)$'

Resources:
  GitHubOIDCProvider:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: https://token.actions.githubusercontent.com
      ClientIdList:
        - sts.amazonaws.com

  DemoRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: iamrole-for-oidc-demo
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Federated: !Ref GitHubOIDCProvider
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
              StringLike:
                token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}@${GitHubOrgId}/${RepositoryName}@${RepositoryId}:ref:refs/heads/main

Outputs:
  RoleArn:
    Value: !GetAtt DemoRole.Arn

The organization ID is a value you only need to look up once, so I assume the actual value is written into the parameter's default value.
On the other hand, the repository ID is rarely seen day to day, and making everyone look it up at every setup is not realistic, so I set the default to *.

However, with * as is, if you recreate a repository with the same name in the same organization, it passes the condition even though the ID has changed.
Since StringLike matches values without wildcards by exact match, in environments where you want to be strict, pass the actual number for the repository ID.

Also, * in StringLike matches any string including /, so filling in a value like 12* would let other IDs through as well.
This is why AllowedPattern is attached to RepositoryId, restricting accepted values to a digit string or a lone *.

By the way, the IDs can be found in id and owner.id of the response of GET /repos/{owner}/{repo}, but for a demo, using the values shown in the diagnostic step's sub is handy.

After fixing the template, pass the organization ID and update the stack.

aws cloudformation deploy \
  --template-file bootstrap.yaml \
  --stack-name github-oidc-demo \
  --parameter-overrides GitHubOrg=octo-org GitHubOrgId=123456 RepositoryName=octo-repo \
  --capabilities CAPABILITY_NAMED_IAM

After the update, open the role's "Trust relationships" tab and you can confirm that the sub condition has moved to StringLike and is in the new format with IDs.

Trust relationships tab with sub fixed to the new format

The organization ID is fixed to a number, and only the position of the repository ID is *.

Verification

After fixing the trust policy, run the workflow with "Run workflow".
This time Configure AWS credentials succeeded, and the output of the Get caller identity step was as follows!

{
    "UserId": "AROAXXXXXXXXXXXXXXXXX:GitHubActions",
    "Account": "***",
    "Arn": "***"
}

Log of Get caller identity with successful authentication

Thanks to mask-aws-account-id, the entire values of Account and of Arn, which contains the account ID, are replaced with ***.
The evidence that the role could be assumed is the success of Configure AWS credentials and that UserId starts with AROA, which represents a role.

Since authentication succeeded, the Debug OIDC claims step is skipped.
This screen also confirms that claims are not left in the logs in normal runs.

Conclusion

In this article, I reproduced the error caused by the immutable subject claim, which is the cause of AWS authentication failing with GitHub Actions OIDC, and walked through the steps of checking the sub claim and fixing the trust policy.

When only a new repository fails authentication, the cause may not be the template or workflow, but that the format of the sub issued by GitHub has changed.
Because ID tokens are masked in logs, whether you can see the actual sub greatly changes the time it takes to isolate the cause.

If you are similarly troubled by authentication failing only in new repositories, please start by checking the actual sub with the diagnostic step from this article!

That was Tsukuboshi (tsukuboshi0755)!

Share this article

AWSのお困り事はクラスメソッドへ