GitHub Actions OIDC with AWS: deploy without access keys
Connect GitHub Actions to AWS with OIDC: the IAM identity provider, a role trust policy scoped by sub claim, configure-aws-credentials, and common errors.
In this series23 guides, you're on OIDC with AWS
Short answer: with OpenID Connect (OIDC), a GitHub Actions job gets temporary AWS credentials for each run, so you store no AWS access keys in GitHub. You set it up once per AWS account:
- Add GitHub as an identity provider in IAM:
https://token.actions.githubusercontent.com, with the audiencests.amazonaws.com. - Create an IAM role whose trust policy admits only your repository, and the branch or environment you choose.
- In the workflow, grant
id-token: writeand callaws-actions/configure-aws-credentials↗ with the role’s ARN.
id-token: write The rest of this guide shows each step, how to choose the trust policy’s conditions, and how to fix the errors you’re likely to hit.
How it works#
When a job has the id-token: write permission, it can ask GitHub for a short-lived OIDC token: a signed JSON Web Token (JWT) whose claims describe the run, such as the repository, the branch, the environment and the event. configure-aws-credentials sends that token to AWS STS (AssumeRoleWithWebIdentity). STS checks the signature against the identity provider in your account, checks the claims against the role’s trust policy, and returns credentials for the role, valid for one hour by default. The action exports them as AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN, so every later step that uses the AWS CLI or an SDK picks them up.
configure-aws-credentials makes the requests in steps 1 and 3, and exports the credentials it gets in
step 5. When the claims don't match the trust policy, step 4 fails with
Not authorized to perform sts:AssumeRoleWithWebIdentity.
Nothing long-lived leaves AWS, and there is no key to rotate. What decides who gets in is the trust policy, so most of this guide is about writing it.
1. Add the identity provider#
Each AWS account needs one OIDC identity provider for GitHub, shared by all the roles in that account. In the IAM console, open Identity providers, click Add provider, and choose OpenID Connect:
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com, the audienceconfigure-aws-credentialsrequests by default.
Or with the AWS CLI:
aws iam create-open-id-connect-provider \ --url https://token.actions.githubusercontent.com \ --client-id-list sts.amazonaws.comOlder instructions ask for a certificate thumbprint. You don’t need one: for GitHub, AWS checks the certificate against its own list of trusted certificate authorities, and ignores a thumbprint if you give one. If the provider already exists in the account, creation fails with EntityAlreadyExists: reuse it.
With Terraform:
resource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"]}2. Create the role and its trust policy#
Create a role with a web identity trust, then attach the permissions the workflow needs, and no more. The trust policy is what matters:
{ "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:sub": "repo:my-org/my-repo:ref:refs/heads/main" } } } ]}aws iam create-role --role-name deploy-my-repo \ --assume-role-policy-document file://trust-policy.jsonThe sub (subject) condition is the one that keeps everyone else out. GitHub’s provider is the same for every repository on github.com, so without it, any workflow in any repository could assume your role. IAM checks for this when you create or update the trust policy: it refuses a policy for GitHub’s provider that has no sub condition, or one that is only a wildcard. Since May 2026 it also accepts a job_workflow_ref condition in its place.
Old and new sub formats#
The example above uses the format of repositories created before July 15, 2026. Repositories created after that date, older repositories that opt in, and repositories renamed or transferred since then use an immutable format that adds the owner and repository IDs:
repo:my-org/my-repo:ref:refs/heads/main # older repositoriesrepo:my-org@123456/my-repo@456789:ref:refs/heads/main # immutable formatThe IDs never change, so if the organization or repository name is ever deleted and taken by someone else, their workflows can’t match your trust policy. A policy written in one format doesn’t match tokens in the other, and the job fails with Not authorized to perform sts:AssumeRoleWithWebIdentity. To find the IDs:
gh api repos/my-org/my-repo --jq '"\(.owner.id) \(.id)"'Before you opt an existing repository in (in its OIDC settings, or the organization’s), update the trust policies that name it. During the switch, a condition can list both forms, since a condition key with several values matches any of them.
3. Use the role in a workflow#
name: Deploy
on: push: branches: [main]
permissions: id-token: write # lets jobs request an OIDC token contents: read # for actions/checkout
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/deploy-my-repo aws-region: us-east-1 role-session-name: deploy-${{ github.run_id }} - run: aws sts get-caller-identity - run: ./deploy.shid-token: writeonly lets the job request a token. It grants no write access to anything else. Once a workflow setspermissions, every permission it doesn’t list isnone, so listcontents: readtoo, oractions/checkoutfails. The permission can also go on the one job that needs it.role-session-nameshows up in CloudTrail, so setting it to the run ID tells you which run made each AWS call. The default isGitHubActions.role-duration-secondssets how long the credentials last: one hour by default, up to the role’s maximum session duration (one hour unless you raise it, at most 12 hours). A deploy that runs longer than that fails partway through.
aws sts get-caller-identity is a cheap first step: it prints the assumed role, so you know the setup works before the deploy starts.
Choosing the sub condition#
The token’s sub depends on how the job runs. The first rule that applies wins:
| The job | sub (older format) |
|---|---|
| References an environment | repo:my-org/my-repo:environment:production |
Runs on a pull_request event | repo:my-org/my-repo:pull_request |
| Runs on a branch | repo:my-org/my-repo:ref:refs/heads/main |
| Runs on a tag | repo:my-org/my-repo:ref:refs/tags/v1.4.0 |
A : inside a value, such as an environment named prod:eu, appears as %3A.
Some consequences:
- Adding
environment:to a job changes itssub. A role that trustsref:refs/heads/mainstops working for that job until you trustenvironment:productioninstead. - A branch condition trusts every workflow that runs on that branch. That includes
pull_request_target,workflow_runandissue_commentruns, which run on the default branch even when a fork’s pull request starts them. If one of them checks out the pull request’s code, that code can assume the role. See pull_request vs pull_request_target. - Pull requests from forks get no OIDC token. Their
GITHUB_TOKENis read-only andid-token: writecan’t be granted, unless an admin has turned on sending write tokens to fork pull requests. A role that trustspull_requestis therefore used by pull requests from branches of the repository itself.
For a deploy role, trust an environment and protect it with required reviewers and a branch rule, so the role is only reachable from a job that passed them. Give pull requests a separate role with read-only permissions, for example for terraform plan.
Wildcards#
To match several values, use StringLike with *, still scoped to your repository:
"Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/tags/v*" }}Under StringEquals, a * is matched literally, so the condition doesn’t match. Watch organization-wide patterns: repo:my-org/* doesn’t match the immutable format, so it doesn’t admit repositories created since July 15, 2026. repo:my-org@123456/* does, and only matches the organization with that ID.
Conditions on other claims#
Since early 2026, AWS STS also checks some of GitHub’s other claims, which you can use as condition keys next to sub. Among them:
token.actions.githubusercontent.com:ref: the branch or tag. Use it with an environmentsub, which doesn’t name the branch.token.actions.githubusercontent.com:repository_idand:repository_owner_id: IDs that survive renames.token.actions.githubusercontent.com:job_workflow_ref: the reusable workflow that runs the job.token.actions.githubusercontent.com:environment,:actor_id,:enterprise_id.
AWS’s list of keys for OIDC federation ↗ has the full set. For example, a role that only main can use through the production environment:
"Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:production", "token.actions.githubusercontent.com:ref": "refs/heads/main" }}GitHub’s own AWS page still says AWS doesn’t support custom claims. That predates the change.
Reusable workflows: job_workflow_ref#
When a job runs inside a reusable workflow, the token’s sub, ref and workflow describe the caller, and job_workflow_ref names the called workflow, such as my-org/deploy-workflows/.github/workflows/deploy.yml@refs/heads/main. A condition on it lets a role trust one central deploy workflow, whichever repository calls it:
"Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:repository_owner_id": "123456", "token.actions.githubusercontent.com:job_workflow_ref": "my-org/deploy-workflows/.github/workflows/deploy.yml@refs/heads/main" }}Keep the owner condition: any repository that can read deploy-workflows can call the workflow, and if it’s public, that’s any repository on GitHub.
The older way, which also works with providers that only check sub, is to make job_workflow_ref part of the sub itself. An admin sets a template for the repository (or the organization) through the REST API:
gh api -X PUT repos/my-org/my-repo/actions/oidc/customization/sub --input - <<'EOF'{"use_default": false, "include_claim_keys": ["repo", "context", "job_workflow_ref"]}EOFThe sub then reads repo:my-org/my-repo:environment:production:job_workflow_ref:my-org/deploy-workflows/.... Update the trust policies before you change the template: every job’s token changes format at once, and the immutable IDs stay in the repo part.
Several AWS accounts#
Each account needs its own identity provider and role, and the workflow picks the role for the account it deploys to. For example, set the role ARN and account ID as variables on each GitHub environment, and read them in a job that references the environment:
- uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} aws-region: eu-west-1 allowed-account-ids: ${{ vars.AWS_ACCOUNT_ID }}allowed-account-ids makes the step fail if the credentials belong to another account, which catches a role ARN pasted into the wrong environment.
To reach a second role from the first, call the action again with role-chaining: true. The second role’s trust policy names the first role’s ARN, and allows both sts:AssumeRole and sts:TagSession: the action tags the session by default. Set role-skip-session-tagging: true on the second call to do without sts:TagSession. Credentials from a chained role last at most one hour.
When it fails#
| Error | Usual cause |
|---|---|
Not authorized to perform sts:AssumeRoleWithWebIdentity | The token’s sub doesn’t match the trust policy: an environment: was added, the repository uses the immutable format, or a wildcard is under StringEquals. Also a wrong aud, or a role in an account without the identity provider. |
Credentials could not be loaded, please check your action inputs | The job has no id-token: write. The log line before it asks whether you meant to set it. |
is not authorized to perform: sts:AssumeRole | Also a missing id-token: write, on a runner that has AWS credentials of its own, such as an EC2 instance role: the action used those to call AssumeRole instead. |
The requested DurationSeconds exceeds the MaxSessionDuration | role-duration-seconds is above the role’s maximum session duration. |
ExpiredToken partway through a job | The credentials outlived their duration. Raise it, or call the action again before the step that needs them. |
To see what the token actually says, print its claims (not the token) from a job in a private repository:
- name: Print the OIDC token's claims run: | curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" \ | jq -r .value \ | jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson | {sub, aud, ref, environment, event_name, job_workflow_ref}'The two variables only exist in a job with id-token: write. GitHub’s actions-oidc-debugger ↗ action prints the same claims.
Checklist#
- One identity provider per account, with the audience
sts.amazonaws.com. - Every trust policy has an
audcondition and asubcondition that names your repository, in the format your repository uses. - Deploy roles trust an environment with required reviewers. Pull requests get a separate, read-only role.
- The role’s permissions cover what the workflow does and nothing else.
id-token: writeonly on the jobs that assume a role.- Delete the AWS access keys you stored as secrets once the workflows no longer use them.
On RunsOn#
OIDC works the same on RunsOn runners: GitHub issues the token, not the runner, so the steps above don’t change. The token’s runner_environment claim is self-hosted.
RunsOn runners also have an IAM instance role, and you can extend it with RunnerCustomPolicy on Flex or runner_custom_policy_arns on Fleet. That’s convenient for permissions every job may have, such as pulling from a shared ECR repository. But the instance role is the same for every job on the stack, from every repository it serves, and nothing in it depends on the branch or the environment. Use an OIDC role for anything that should: deploys, production data, other accounts. The security page explains what the runner role can and can’t reach.
Because the runner has credentials of its own, a job that forgets id-token: write fails with is not authorized to perform: sts:AssumeRole naming the runner’s role, not with “Credentials could not be loaded”.