quickstart →

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.

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:

  1. Add GitHub as an identity provider in IAM: https://token.actions.githubusercontent.com, with the audience sts.amazonaws.com.
  2. Create an IAM role whose trust policy admits only your repository, and the branch or environment you choose.
  3. In the workflow, grant id-token: write and call aws-actions/configure-aws-credentials ↗ with the role’s ARN.

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.

One job, step by step
Workflow job
GitHub OIDC provider
AWS STS
Your AWS resources
job → GitHub request a token
GitHub → job signed token with its claims
job → STS AssumeRoleWithWebIdentity: role ARN and token
STS checks the signature, then the claims against the role's trust policy
STS → job credentials for the role, one hour by default
job → AWS AWS CLI and SDK calls, as the role
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 audience configure-aws-credentials requests 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.com

Older 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:

main.tf
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:

trust-policy.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: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.json

The 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 repositories
repo:my-org@123456/my-repo@456789:ref:refs/heads/main # immutable format

The 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#

.github/workflows/deploy.yml
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.sh
  • id-token: write only lets the job request a token. It grants no write access to anything else. Once a workflow sets permissions, every permission it doesn’t list is none, so list contents: read too, or actions/checkout fails. The permission can also go on the one job that needs it.
  • role-session-name shows up in CloudTrail, so setting it to the run ID tells you which run made each AWS call. The default is GitHubActions.
  • role-duration-seconds sets 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 jobsub (older format)
References an environmentrepo:my-org/my-repo:environment:production
Runs on a pull_request eventrepo:my-org/my-repo:pull_request
Runs on a branchrepo:my-org/my-repo:ref:refs/heads/main
Runs on a tagrepo: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 its sub. A role that trusts ref:refs/heads/main stops working for that job until you trust environment:production instead.
  • A branch condition trusts every workflow that runs on that branch. That includes pull_request_target, workflow_run and issue_comment runs, 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_TOKEN is read-only and id-token: write can’t be granted, unless an admin has turned on sending write tokens to fork pull requests. A role that trusts pull_request is 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 environment sub, which doesn’t name the branch.
  • token.actions.githubusercontent.com:repository_id and :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"]}
EOF

The 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:

.github/workflows/ci.yml
- 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#

ErrorUsual cause
Not authorized to perform sts:AssumeRoleWithWebIdentityThe 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 inputsThe job has no id-token: write. The log line before it asks whether you meant to set it.
is not authorized to perform: sts:AssumeRoleAlso 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 MaxSessionDurationrole-duration-seconds is above the role’s maximum session duration.
ExpiredToken partway through a jobThe 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:

.github/workflows/ci.yml
- 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 aud condition and a sub condition 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: write only 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”.