Skip to content

GitHub Actions OIDC Trust Policy for AWS

A GitHub Actions OIDC AWS trust policy that accepts both subject formats, pins your org by ID, and lets you retire CI access keys without breaking a build.

15 min read

Plenty of AWS accounts have an IAM user that exists only so a GitHub Actions workflow can push an image or run a deploy. Its access key sits in a repository secret and never expires. GitHub Actions OIDC is the better answer.

The trust policy is where this goes wrong, and it got harder this year. Create a repository in your GitHub organization today and its Actions jobs present a different identity to AWS than the repository next to it. The older one says repo:example-org/api:ref:refs/heads/main, while the new one says repo:example-org@1234567/api@456789:ref:refs/heads/main. A trust policy written only for the first format denies the second, and the error tells you nothing about why.

I designed around that trap when I built GitHub OIDC roles for CI image pushes in a multi-account AWS estate, trusted by every repository across two GitHub organizations, so the long-lived access keys those pipelines use can be retired. A GitHub Actions OIDC AWS trust policy that survives this needs three things: accept both subject formats, pin the organization by its numeric ID, and prove the policy against every repository before it ships. After that you switch workflows over additively and retire keys only after watching them go quiet. Done in that order, every step can be rolled back by reverting one workflow file while the old keys still work. More of the governance work is in my AWS multi-account governance case study.

How GitHub OIDC reaches AWS#

OIDC (OpenID Connect, a standard for one system to vouch for an identity to another) replaces the stored key. GitHub signs a short-lived token describing the job, and AWS trades it for temporary credentials if the role's trust policy accepts what the token says.

sequenceDiagram participant J as Workflow job participant G as GitHub OIDC provider participant S as AWS STS participant E as Amazon ECR J->>G: Request ID token (aud sts.amazonaws.com) G-->>J: Signed JWT with sub, repository_owner_id, ... J->>S: AssumeRoleWithWebIdentity(role, JWT) S->>S: Verify signature, evaluate trust policy conditions S-->>J: Temporary credentials (1 hour by default) J->>E: Push image with temporary credentials

STS issues credentials only when the token's claims satisfy the role's trust policy.

The job needs id-token: write permission to request the token. The aws-actions/configure-aws-credentials action (currently v6) does the exchange. Its default audience is sts.amazonaws.com, and its default session is one hour, adjustable from 15 minutes to 12 hours. GitHub's guide to OIDC in AWS covers the provider setup. If you lead a team, ask how many IAM users exist only so CI can reach AWS; each is a candidate for OIDC.

Two subject formats, side by side#

Trust policies match on the sub claim. GitHub's OIDC reference builds it from the repository plus one context: an environment, a pull request, a branch or a tag.

Job context Name-only format Immutable format
Job references an environment repo:ORG/REPO:environment:NAME repo:ORG@ORG-ID/REPO@REPO-ID:environment:NAME
Pull request event, no environment repo:ORG/REPO:pull_request repo:ORG@ORG-ID/REPO@REPO-ID:pull_request
Branch, no environment, not a pull request repo:ORG/REPO:ref:refs/heads/BRANCH repo:ORG@ORG-ID/REPO@REPO-ID:ref:refs/heads/BRANCH
Tag, no environment, not a pull request repo:ORG/REPO:ref:refs/tags/TAG repo:ORG@ORG-ID/REPO@REPO-ID:ref:refs/tags/TAG

Per GitHub's reference, the pull request, branch and tag forms apply only when the job references no environment.

The immutable format is the default for repositories created after July 15, 2026. Older repositories keep the name-only format unless the organization or repository opts in, and the immutable format isn't available on GitHub Enterprise Server.

A rename flips the format

Repository renames and transfers after July 15, 2026 also move to the immutable format. A name-only policy stops matching a repository the day someone renames it.

IDs matter because the OIDC specification requires a subject to be "locally unique and never reassigned", and a recycled name breaks that. GitHub separates name and ID with @, which cannot appear in a GitHub name, after researchers showed an earlier - delimiter was squattable1. AWS's condition key reference agrees:

AWS IAM documentation: use immutable identifiers, not names

A name that is freed by renaming or deletion can be claimed by a different account. Policies that rely solely on mutable name-based claims (such as repository or actor) could grant access to unintended identities.

If your organization has created or renamed a repository since July 15, 2026, check which format each repository now uses. Then ask whether your AWS roles accept both before the next new one needs to deploy.

Writing the GitHub OIDC trust policy#

When you create or update a role that trusts GitHub, IAM checks that token.actions.githubusercontent.com:sub is present and is not solely a wildcard or null, and fails the request otherwise. That rules out trusting by organization ID alone.

You cannot trust by organization ID alone

A policy with only repository_owner_id is rejected. Existing roles are not re-evaluated until someone edits their trust policy, so an old, loose role keeps working until its next edit fails.

The sub condition lists both formats for your organization, and a separate repository_owner_id condition pins the organization by its numeric ID. AWS evaluates multiple values for one key as a logical OR and separate keys or operators as a logical AND, so the token must match one of the sub patterns and carry the right owner ID and the right audience. The slash in each pattern matters: repo:example-org/* doesn't match repo:example-orgX/..., because the character after example-org must be /. The four variants run from widest to narrowest. Account 123456789012, example-org and the IDs are placeholders.

trust-policy-org-wide.json
{
  "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",
          "token.actions.githubusercontent.com:repository_owner_id": "1234567"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": [
            "repo:example-org/*",
            "repo:example-org@1234567/*"
          ]
        }
      }
    }
  ]
}
trust-policy-one-repo.json
{
  "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",
          "token.actions.githubusercontent.com:repository_owner_id": "1234567",
          "token.actions.githubusercontent.com:repository_id": "456789"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": [
            "repo:example-org/example-repo:*",
            "repo:example-org@1234567/example-repo@456789:*"
          ]
        }
      }
    }
  ]
}
trust-policy-environment.json
{
  "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",
          "token.actions.githubusercontent.com:repository_owner_id": "1234567",
          "token.actions.githubusercontent.com:repository_id": "456789",
          "token.actions.githubusercontent.com:sub": [
            "repo:example-org/example-repo:environment:production",
            "repo:example-org@1234567/example-repo@456789:environment:production"
          ]
        }
      }
    }
  ]
}
trust-policy-ref-main.json
{
  "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",
          "token.actions.githubusercontent.com:repository_owner_id": "1234567",
          "token.actions.githubusercontent.com:repository_id": "456789",
          "token.actions.githubusercontent.com:sub": [
            "repo:example-org/example-repo:ref:refs/heads/main",
            "repo:example-org@1234567/example-repo@456789:ref:refs/heads/main"
          ]
        }
      }
    }
  ]
}

For the image-push roles I built, I chose org-wide. Each environment account got one OIDC provider and one narrowly scoped role, which can do only the push and pull actions the existing CI key's IAM user uses, only against its own account's registries, with the session capped at four hours. The worst case is a compromised workflow in any repository overwriting an image tag that production pulls. Tag immutability on the registries, or one role per registry, narrows that further.

I rejected a named repository allow-list, because an allow-list is only as complete as the inventory behind it. One built from default-branch workflows misses repositories that run only on other branches, every new repository needs a policy change before it can push, and a name-only list denies every new-format repository. I'd rather accept breadth in the trust policy and keep the permission policy narrow.

AWS warns that sub must be limited to your organization or repository, and strongly recommends protection rules wherever you use GitHub environments. For a role that can change production, I go further: scope it by environment or ref, behind protection rules. Org-wide trust is a deliberate exception I make only when the permission policy is narrow.

I also left GitHub's subject customization (include_claim_keys) alone. A custom template changes the sub for every repository that adopts it, so every trust policy those repositories reach has to change in step with it, and the default format needs no such coordination. Whatever width you choose, ask for a one-line note per role: who can assume it, and the worst case if a workflow is compromised.

Prove it against every repository#

A trust policy that looks right is still only a claim, so before shipping I checked it against every repository in both organizations. The first step reads each repository's subject template. The REST endpoint returns use_default, include_claim_keys, use_immutable_subject and sub_claim_prefix.

audit-oidc-subjects.sh
#!/usr/bin/env bash
set -euo pipefail

for org in example-org example-org-two; do
  gh repo list "$org" --limit 1000 --json nameWithOwner --jq '.[].nameWithOwner' |
    while read -r repo; do
      gh api "repos/${repo}/actions/oidc/customization/sub" \
        --jq "[\"${repo}\", .use_default, (.use_immutable_subject // false), (.include_claim_keys // [] | join(\",\"))] | @tsv" ||
        printf '%s\tERROR\n' "$repo"
    done
done

gh needs a token with the repo scope, and --limit 1000 caps the list silently, so raise it for larger organizations. Any repository with a custom template or the immutable flag is one your policy must handle. When I ran this check across both organizations, several recently created repositories already used the new format.

The second step is a simulation. I generated the sub each repository would send for a fixed set of trigger shapes and matched it against the policy's patterns locally. This was a local pattern-match simulation, not the IAM policy simulator. Every real repository was allowed.

A minimal version of the local check

fnmatchcase treats * and ? the way StringLike does. It also treats [ specially, which StringLike does not, so this is a sanity check rather than an IAM evaluation.

simulate_trust.py
from fnmatch import fnmatchcase

OWNER_ID = "1234567"
PATTERNS = ["repo:example-org/*", "repo:example-org@1234567/*"]


def allowed(sub: str, owner_id: str) -> bool:
    return owner_id == OWNER_ID and any(fnmatchcase(sub, p) for p in PATTERNS)


cases = {
    "repo:example-org/api:ref:refs/heads/main": ("1234567", True),
    "repo:example-org@1234567/api@456789:pull_request": ("1234567", True),
    "repo:example-orgx/api:ref:refs/heads/main": ("1234567", False),
    "repo:other-org/api:ref:refs/heads/main": ("1234567", False),
    "repo:example-org-fork/api:pull_request": ("1234567", False),
    "repo:someone@999/example-org@1234567:ref:refs/heads/main": ("1234567", False),
}

for sub, (owner_id, expected) in cases.items():
    assert allowed(sub, owner_id) is expected, sub
print("all cases match")

Allows alone prove half the policy, so the third step is a set of negative controls: subjects that should never get credentials.

Negative control What it imitates Result
Look-alike organization name Your org name plus one character Denied
Foreign organization Any other GitHub organization Denied
Fork-style owner An organization named after yours with a suffix, such as example-org-fork Denied
Embedded @ identity Another owner whose sub carries your org@ID after its own name Denied

Each was denied by the sub patterns alone, and the owner-ID condition is a second, independent check. The last two steps cover permissions. I pulled the existing key's last 90 days of API calls and confirmed every one fit inside the new role's allowed actions, and IAM Access Analyzer policy validation returned no errors. Before a trust policy ships, ask for that evidence: every repository simulated, negative cases denied, the existing key's real usage covered.

Switch workflows over additively#

The role sits alongside the keys, so you can switch one workflow at a time while the old keys still work as a fallback. For a typical image-push workflow the diff is small.

.github/workflows/push-image.yml
name: push-image
on:
  push:
    branches: [main]

jobs:
  push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: aws sts get-caller-identity
.github/workflows/push-image.yml
name: push-image
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  push:
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v6
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          role-duration-seconds: 3600
          aws-region: us-east-1
      - run: aws sts get-caller-identity

Three details in that diff matter:

  • contents: read stays. A permissions block sets every scope you do not list to none, and actions/checkout needs read access.
  • The role ARN lives in a GitHub environment variable, one per environment, which keeps dev, staging and prod roles apart.
  • environment: dev changes the sub. The job now sends repo:...:environment:dev, not ref:refs/heads/main. The org-wide pattern accepts both. A branch-scoped policy would deny it.

role-duration-seconds must not exceed the role's maximum session duration. I show tags for readability; pin both actions to a full commit SHA in real workflows, as GitHub's own example does.

Roll out in environment order: dev, then staging, then prod. For each one, verify three ways: the run succeeds, CloudTrail, in the Region the workflow sets as aws-region, shows an AssumeRoleWithWebIdentity event whose userIdentity.userName is the actual sub, and the role's last activity in IAM updates. If anything fails, revert the workflow change, because the keys still work. Ask for one environment at a time, a named rollback and CloudTrail proof, not just a green build.

Retire the keys, then close the door#

Retirement runs per environment. These two commands check when a key was last used and switch it off without deleting it.

check-and-deactivate-key.sh
aws iam get-access-key-last-used --access-key-id "${ACCESS_KEY_ID}"

aws iam update-access-key \
  --user-name "${CI_USER_NAME}" \
  --access-key-id "${ACCESS_KEY_ID}" \
  --status Inactive

The sequence for each environment:

  1. Confirm every workflow in that environment uses the role.
  2. Watch GetAccessKeyLastUsed through a quiet period, for example seven days, with no key use.
  3. Deactivate the key, then wait one release cycle.
  4. Delete the GitHub secrets that held it.
  5. Delete the key, then the IAM user.

Deactivate before you delete

An inactive key can be reactivated in seconds if a forgotten job surfaces. A deleted key cannot. Also look for keys created outside your infrastructure-as-code state: a terraform destroy of the user fails while such a key still exists, because IAM will not delete a user that has an access key.

The last step of the method is a guardrail so new keys don't creep back: an SCP (a service control policy, an organization-level policy that can only deny) that blocks IAM user and key creation for everyone except your landing-zone automation. Stage it before enforcing it.

scp-deny-iam-user-credentials.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyLongLivedIamUserCredentials",
      "Effect": "Deny",
      "Action": [
        "iam:CreateUser",
        "iam:CreateAccessKey",
        "iam:CreateLoginProfile"
      ],
      "Resource": "*",
      "Condition": {
        "ArnNotLike": {
          "aws:PrincipalArn": [
            "arn:aws:iam::*:role/example-landing-zone-automation",
            "arn:aws:iam::*:role/example-break-glass-admin"
          ]
        }
      }
    }
  ]
}

With multiple values under a negated operator, AWS evaluates them as a logical NOR: the deny applies unless the caller matches one of the exempt roles. SCPs do not affect the management account or service-linked roles, so they are not a complete fence. Denying CreateAccessKey also blocks key rotation for any IAM user still in service, so retire those first or exempt them deliberately.

Before attaching, search CloudTrail for the last 90 days of CreateUser, CreateAccessKey and CreateLoginProfile calls. Every caller you find either moves off keys first or goes on the exemption list. For AWS Control Tower, the exemptions include AWSControlTowerExecution. Attach to a small OU first. If you are weighing an SCP against the newer policy types, I compare them in SCP vs RCP vs declarative policies.

"Keys retired" should mean deleted after a watched quiet period, not deactivated and forgotten. Ask for the guardrail to land last, on a test OU first.

Migration checklist#

Track the method per environment. Close the ticket only when every box is ticked.

  • Inventory every IAM user whose keys live in GitHub secrets
  • Audit every repository's OIDC subject template with the gh api script
  • One OIDC provider per account; one role per environment, permissions copied from the existing key's real usage
  • Trust policy accepts both sub formats and pins repository_owner_id
  • Local simulation of every repository, plus negative controls
  • Access Analyzer validation clean
  • Workflows switched dev, staging, prod, each verified in CloudTrail
  • Quiet period observed per key, then deactivate, wait, delete secrets, delete key and user
  • Credential-creation SCP staged on a test OU, then enforced

What to do next#

Run the audit script against your own organization; it's read-only. If any repository reports use_immutable_subject as true and your trust policies only list names, you have found a repository that will be denied the first time a workflow there tries to assume one of those roles.

Then check your CI roles' sub conditions against the two-format table in Two subject formats, side by side. If you want to talk through the rollout order before touching a pipeline, book a free intro call.

Frequently asked questions

What is the immutable subject claim in GitHub Actions OIDC?

It is a sub format that carries the owner and repository IDs next to their names, such as repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. It is the default for repositories created after July 15, 2026 and for repositories renamed or transferred after that date. Older repositories keep the name-only format unless the organization or repository opts in.

Can an AWS trust policy for GitHub OIDC check only the organization ID?

No. IAM requires a condition on token.actions.githubusercontent.com:sub when you create or update a role that trusts GitHub, and rejects one that is only a wildcard or null. Keep a sub condition that names your organization in both formats, and add repository_owner_id as a separate StringEquals condition to pin the organization by its numeric ID.

Does adding an environment to a GitHub Actions job change the OIDC subject?

Yes. When a job references an environment, the default sub becomes repo:ORG/REPO:environment:NAME. The ref and pull_request forms appear only when the job does not reference an environment. A trust policy that only allows ref:refs/heads/main will deny that job, so update the policy before you add environments to deploy jobs.

Do I still need a thumbprint for the GitHub OIDC provider in AWS?

Usually not. AWS verifies the identity provider's JWKS TLS certificate against its own library of trusted root certificate authorities and falls back to configured thumbprints only in limited cases. The thumbprint is optional when you create the provider through the CLI or API. Older Terraform AWS provider versions required thumbprint_list, so you may still see one in existing code.

Found this useful? Share it

AWSDevOpsSecurity

Written by Shahid Yousuf

Senior Software Engineer building secure, scalable web applications, AI solutions and cloud infrastructure for businesses worldwide. Work with me or follow along via RSS.

Shahid Yousuf

Senior Software Engineer

I build and consult on software across web, mobile, cloud and AI. Have something in mind? Let's talk.

Hire me Book a free intro call See case studies

Get new posts

No newsletter, no tracking. Follow along in any feed reader.

SCP vs RCP vs Declarative Policies in AWS

Deny a verb or assert a state? A practical guide to choosing between SCPs, RCPs and declarative policies, with examples and a safe rollout checklist.

AWS13 min read

Move an AWS Account to Another Organization

The order of operations for moving an AWS account between organizations: invite from the destination, enroll in Control Tower, then close the sign-on, guardrail and old-role gaps.

AWS12 min read

Have a project in mind?

Share your goals, timeline and any constraints, whether you need it built or want expert advice. I read every message myself, as the engineer who would build it, and reply with a clear view on approach, scope and next steps.

Hire me