Deploy to AWS from GitHub Actions with OIDC

By Vishvesh Patel · DevOps EngineerUpdated September 28, 2026

Replace stored AWS keys with short-lived credentials. The IAM trust policy explained claim by claim, the sub condition that scopes it to your repository, and the errors each mistake produces.

Storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as repository secrets works, and it means a long-lived credential to your AWS account exists in a system outside AWS, never expires on its own, and is readable by any workflow in the repository. OpenID Connect removes the stored key entirely: the workflow presents a short-lived token that GitHub signs, AWS verifies it against GitHub's public keys, and returns temporary credentials.

There is nothing to rotate and nothing to leak. This is the single biggest security improvement available to most pipelines.

Verified against aws-actions/configure-aws-credentials@v4 as of September 2026.

How the Exchange Works

Four steps, and understanding them makes every error message legible.

The workflow requests a JSON Web Token from GitHub's OIDC endpoint. This requires permissions: id-token: write, which is what allows a job to mint one.

The token contains claims describing the run: iss is https://token.actions.githubusercontent.com, aud is the intended audience, and sub identifies the repository and context, for example repo:my-org/my-repo:ref:refs/heads/main.

The action calls sts:AssumeRoleWithWebIdentity, presenting the token.

AWS validates the signature against GitHub's published keys, checks the token's claims against the role's trust policy, and if they match returns credentials valid for one hour by default.

The security property comes from the sub claim: because GitHub controls it and it names your repository and ref, a token from a different repository cannot satisfy a trust policy scoped to yours.

Creating the Identity Provider

Once per AWS account:

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com

Older guides include a --thumbprint-list with a certificate fingerprint. AWS stopped requiring that for this provider in 2023 and now validates against trusted root CAs, so a hardcoded thumbprint is at best redundant and at worst a future outage when the certificate rotates. If you have one in Terraform, it is safe to drop.

Check whether the provider already exists before creating a second one:

aws iam list-open-id-connect-providers

The Trust Policy

This is where nearly all the difficulty lives.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Every element earns its place.

The aud condition must be present. Without it, any GitHub Actions token from any repository on GitHub satisfies the principal, and the only thing standing between your account and the world is the sub condition. Both conditions, always.

The sub condition is what scopes the role. The format is repo:OWNER/REPO: followed by a context specifier:

  • ref:refs/heads/main for a push or workflow run on that branch
  • ref:refs/tags/v1.0.0 for a tag
  • pull_request for a pull-request-triggered run
  • environment:production when the job declares that environment

Being precise here is the whole point. repo:my-org/my-repo:* permits any branch, any tag and any pull request in that repository, which means anyone who can push a branch can assume your deployment role. That is a materially weaker posture than the stored key you were trying to improve on.

Scope to a branch for a deploy role, and prefer environment:production when you have a protected environment, because that adds the approval gate to the credential itself.

For a role used by several branches, StringLike with a narrow pattern:

"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/release/*"

Note that StringEquals does not expand wildcards. A * in a StringEquals value is a literal asterisk, and the resulting policy matches nothing at all while looking correct.

The Workflow

name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy
          role-session-name: gha-${{ github.run_id }}
          aws-region: eu-west-1

      - run: aws sts get-caller-identity
      - run: aws s3 sync ./dist s3://my-bucket --delete

id-token: write at the top level, or on the job. Omitting it is the most common cause of failure, and the error is unhelpful because the action cannot request a token at all.

role-session-name is worth setting. It appears in CloudTrail, so gha-12345678 tells you which run performed an action months later. The default is generic and makes audit trails harder to read.

aws-region is required even for global services, because STS needs an endpoint.

The Role's Permissions

The trust policy says who may assume the role. A separate permissions policy says what the role can do, and it should be minimal:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ]
    }
  ]
}

Both ARN forms are needed: the bucket ARN for ListBucket, and the object ARN with /* for object operations. A policy with only the /* form produces an access-denied on the list call that s3 sync performs first, which reads as a credentials problem and is not one.

If you are debugging an access denial, the AWS access denied page covers how to work out which policy said no, and the IAM roles and policies tutorial covers the evaluation order.

Reading the Errors

Not authorized to perform sts:AssumeRoleWithWebIdentity means the token reached AWS and the trust policy rejected it. The claims did not match. Print the intended subject and compare it character by character against the policy, including the branch name and the repo: prefix. The OIDC could not assume role page lists the specific mismatches in order of likelihood.

Credentials could not be loaded means no token was requested, which is id-token: write missing.

No OpenIDConnect provider found means the provider does not exist in that account, or the ARN in the trust policy names a different account.

Access denied on the AWS command, not on the assume-role, means the exchange succeeded and the role's permissions policy is too narrow. aws sts get-caller-identity as a first step separates these two cases immediately, which is why it is in the workflow above.

Session Duration

Credentials last one hour by default. For a longer deployment, role-duration-seconds raises it up to the role's MaxSessionDuration, which itself defaults to one hour and must be raised on the role before the action can request more.

Prefer keeping it short. A deployment needing more than an hour of continuously valid credentials is usually a deployment that should be broken into stages.

Frequently Asked Questions

Does this work for pull requests from forks?

No, and that is correct. A fork pull request gets no id-token write permission, so no token can be minted. Deployment belongs on a push to a protected branch, not on a pull request.

Can one role serve several repositories?

Yes, with multiple values in the sub condition, and think about whether you want to. A role assumable by several repositories means a compromise of the least protected one grants everything the role can do. Separate roles per repository cost nothing and contain the blast radius.

Do I still need an IAM user anywhere?

Not for GitHub Actions. Once OIDC is in place, delete the access keys you were using and the IAM user if it exists only for CI. Leaving them active means the weaker path still works.

Is this specific to AWS?

No. The same GitHub token works with Google Cloud Workload Identity Federation and with Azure federated credentials. The token and the claims are identical; only the verifying side differs.

How do I test the trust policy without deploying?

Make the first job do nothing but aws sts get-caller-identity. It fails if the trust relationship is wrong and succeeds cheaply if it is right, and it prints the assumed role ARN so you can confirm you got the role you intended.

Continue Learning

Explore Related Topics

Try the Tool

When it breaks

Related Resources