Skip to main content

Deleting the AWS Access Key Wasn't the Security Win: The OIDC Trust Policy Is

If your pipeline no longer holds an AWS access key, what stops someone else's workflow from assuming your deploy role? Moving CI/CD to OpenID Connect (OIDC) feels like a finished security task. I think it's only half done. The key is gone, but the question of who may assume the role is now answered by a condition in an IAM trust policy. If that condition is loose, you have swapped a secret you could leak for a boundary you can misconfigure.

A note on sources: everything below comes from GitHub's and AWS's own documentation, which I re-read on 11 October 2026. The examples are adapted from it, with placeholders. This is not a benchmark or an incident from my own systems, and I'm not publishing any of my own trust policies. Where a point is my inference or my recommendation rather than something the docs state, I say so.

What OIDC removes, and what it leaves for you to decide

GitHub's documentation says OIDC lets workflows access AWS without storing AWS credentials as long-lived GitHub secrets. That is a real improvement: there is no static key to rotate, leak in a log, or copy into a fork's settings.

But the trust relationship still has to be defined, and the same guide is blunt about it: you must define at least one condition, so that untrusted repositories can't request access tokens for your cloud resources. AWS IAM recommends evaluating the token.actions.githubusercontent.com:sub condition key in the trust policy of any role that trusts GitHub's identity provider.

AWS also enforces a floor. According to the IAM documentation for OIDC roles, when GitHub's provider is the trusted principal, IAM checks at create and update time that the sub key is present and that its value is not solely a wildcard. That blocks the worst case, but there is a lot of room between "not just *" and "one deploy workflow". These are the decisions that remain yours:

  • Which repository may assume this role?
  • Which ref or environment within that repository: any branch, only the default branch, or only a protected deployment environment?
  • What can the role do once assumed? A tightly scoped trust policy attached to an admin role still gives you an admin pipeline.

The wildcard that widens the boundary without announcing it

Many tutorials, and the AWS console flow, lead you toward a repository-wide pattern like this one:

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  },
  "StringLike": {
    "token.actions.githubusercontent.com:sub": "repo:<ORG>/<REPO>:*"
  }
}

GitHub's own example for this pattern says it allows any branch, pull request merge branch, or environment from that repository to assume the role. So a pull-request build or a feature branch can satisfy the same condition as main. It is convenient for a first test, but it is not a deployment boundary.

The organization-wide form (repo:<ORG>/*) is weaker still, and AWS says so: if you don't limit sub to a specific organization or repository, workflows from organizations or repositories outside your control can assume the role.

One console detail worth knowing: in the IAM console's GitHub flow, the organization name is required and cannot contain wildcards, but the repository and branch fields default to * when left blank. Clicking through quickly can produce a looser policy than you intended.

What the subject actually contains

The sub claim is assembled from the job's context, and GitHub's OIDC reference documents the default forms:

  • If the job references an environment: repo:ORG/REPO:environment:NAME
  • If the workflow was triggered by a pull request and the job has no environment: repo:ORG/REPO:pull_request
  • Otherwise: repo:ORG/REPO:ref:refs/heads/BRANCH (or refs/tags/TAG)

The consequence is easy to miss. Once a job references an environment, the environment name replaces the branch in the subject. A trust policy pinned to ref:refs/heads/main will not match a job that declares an environment, and a policy pinned to an environment says nothing about which branch ran.

That is why both GitHub and AWS recommend protection rules on any environment used in an OIDC policy, such as deployment branch and tag restrictions. My inference, which I have not tested: an environment-pinned subject is only as strict as that environment's protection rules. Without them, a workflow on any branch that declares the environment could be a candidate for the token. Check this in your own repository.

The subject format changed in July 2026

This is the part most older tutorials miss. According to GitHub's reference, repositories created after 15 July 2026 use an immutable default subject that includes the owner ID and repository ID, for example repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH. GitHub's stated reason is that the old name-only format could be reproduced by a different owner if a namespace was recycled.

Three practical details from the docs:

  • Repositories created before that date keep the old format unless you opt in at the organization or repository level.
  • Repository renames and transfers after that date also move to the immutable format. A trust policy written for the old name-only format can stop matching after a rename or transfer.
  • The rollout does not include GitHub Enterprise Server.

It also explains why examples you find online don't agree on what the subject looks like. Match the format your repository actually emits.

A trust policy and workflow worth starting from

This is the shape I would start from for a single deploy role, adapted from the AWS and GitHub docs:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::<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:<ORG>/<REPO>:environment:<ENV>"
      }
    }
  }]
}

For a repository on the immutable format, the sub line becomes:

"token.actions.githubusercontent.com:sub": "repo:<ORG>@<ORG_ID>/<REPO>@<REPO_ID>:environment:<ENV>"

Both conditions use StringEquals, so there is no wildcard to widen by accident. The aud value is the one GitHub documents for the official AWS action. If you deploy from a branch without an environment, use the ref-form subject instead.

On the workflow side, the job needs the id-token: write permission and the AWS credentials action:

permissions:
  id-token: write   # lets the job request the OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: <ENV>
    steps:
      - uses: actions/checkout@<full-commit-sha>
      - uses: aws-actions/configure-aws-credentials@<full-commit-sha>
        with:
          role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/<DEPLOY_ROLE>
          aws-region: <REGION>
          role-session-name: github-actions-${{ github.run_id }}
      - run: aws sts get-caller-identity

Three lines carry most of the weight:

  • id-token: write allows the job to request a token. GitHub is explicit that it does not grant write access to any resource; without it, the token cannot be requested at all. A missing permission is a likely first error when you migrate.
  • environment must match the subject in the trust policy. If it doesn't, role assumption fails on the AWS side. The error you typically see is "Not authorized to perform sts:AssumeRoleWithWebIdentity", which reads like a permissions problem even though the cause is the subject string.
  • role-session-name is cosmetic in the workflow but useful afterwards. Including the run ID lets you connect an AWS-side event to a specific workflow run when you read the logs later.

Read the token your workflow actually produces

Don't copy a subject string from a blog post, including this one. GitHub publishes the actions-oidc-debugger action, which requests a token and prints the claims in it, so you can see the exact sub before you write the condition.

GitHub also lets admins customize the subject through its REST API, for example to require a reusable workflow reference or a repository ID in the subject. Two cautions from the docs: a customization replaces the whole default format, and you should create the matching condition in your cloud provider before you change the template, otherwise tokens may stop being accepted. Separately, GitHub's AWS guide notes that support for custom claims is unavailable in AWS, so in practice your conditions are built on aud and sub. AWS publishes its own list of supported OIDC condition keys; check it before relying on anything beyond those two.

A verification pass before you delete the old key

  1. Confirm the positive case. Run the deploy from the intended branch or environment and check that aws sts get-caller-identity returns the assumed role.
  2. Confirm the negative cases. Run the same workflow from a feature branch and from a different repository. Both should fail at role assumption. If you use an environment, also try a branch its deployment rules should block. If any of these succeeds, the boundary is wider than you meant.
  3. Check for wildcards. Search every trust policy for StringLike on sub and ask whether each * is deliberate.
  4. Re-check the subject format for any repository created, renamed, or transferred after 15 July 2026, using the debugger action.
  5. Review the permissions policy separately. Scope it to what the pipeline actually touches. The trust policy says who can come in; the permissions policy says what they can do.
  6. Retire the old access key last. My own recommendation: deactivate it first, watch for anything that still depended on it, then delete it after a successful run on the new path.
  7. Pin actions to a full commit SHA. GitHub's own AWS example pins the credentials action that way. The action runs with your cloud identity, so treat updates as code changes and review them.

Where I'd stay cautious

OIDC doesn't make a pipeline safe by itself. It moves the risk from credential storage to policy design. For a small team the most useful habit is to treat the trust policy as reviewed infrastructure: version it, give each deploy target its own role, and test a failed assumption on purpose.

My recommendation is to use OIDC for AWS deployments from GitHub Actions, and to start strict. Pin the subject to one repository and one environment or branch, protect that environment, and loosen the condition only when a specific workflow requires it. I haven't covered multi-account setups or role chaining, and I'd verify those against current AWS documentation before writing about them.

Comments