How Do I Authenticate to AWS With OIDC?

Grant id-token write in the job, exchange the signed token with AWS STS for temporary credentials, and pin the role trust policy to one repository and branch.

Answer

Request an identity token inside the job, exchange it with AWS STS for temporary credentials, and let an IAM role trust policy decide which repository and which ref are allowed to make that exchange. Three pieces have to line up: the id-token: write permission on the job, an IAM OpenID Connect identity provider registered once per AWS account for token.actions.githubusercontent.com, and a role whose trust policy pins the aud and sub claims from the token GitHub signs (GitHub: security hardening with OpenID Connect, checked on 2026-08-13).

Nothing long lived ends up in the repository. The credentials AWS returns expire on their own, and the branch restriction is enforced by AWS against a claim GitHub signed, so editing the workflow file cannot widen it. The exchange happens between the job, the GitHub token endpoint, and the AWS STS endpoint, so it behaves the same on any runner that can reach both. Every label in that catalog runs the exchange the same way.

The workflow side is one grant and one step:

name: publish-image
on:
  push:
    branches: [main]

permissions: {}

jobs:
  publish:
    runs-on: warp-ubuntu-latest-x64-4x
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v5

      - name: Exchange the GitHub identity token for AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-actions-publish
          role-session-name: publish-${{ github.run_id }}
          aws-region: us-east-1

      - uses: aws-actions/amazon-ecr-login@v2
        id: ecr

      - name: Build and push the image
        run: |
          docker build -t "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}" .
          docker push "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}"

permissions: {} at the top of the file sets every scope to none for jobs that stay silent, and the publish job restates the two grants it needs. The credentials action requests a token with audience sts.amazonaws.com, calls sts:AssumeRoleWithWebIdentity, and exports the returned access key, secret key, and session token as environment variables for the remaining steps (aws-actions/configure-aws-credentials).

Detail

Register the identity provider once per AWS account

AWS needs to know the issuer before any role can trust it. The provider is an account level object, so one registration serves every role and every repository in that account:

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

The client ID list is the set of audience values AWS will accept, and sts.amazonaws.com is what the credentials action requests by default. AWS documents when a thumbprint list is required for this call and how to obtain the value (AWS: obtain the thumbprint for an OpenID Connect identity provider). Missing this step is the cause of the No OpenIDConnect provider found in your account error further down.

Scope the trust policy to one repository and one ref

The trust policy is where the security of the pattern lives. It names the provider as a federated principal, allows the one STS action, and compares two claims from the signed token using the condition keys token.actions.githubusercontent.com:aud and token.actions.githubusercontent.com:sub:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "GitHubActionsPublishFromMain",
      "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",
          "token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

The sub value follows the trigger that started the job, and the four shapes below cover most pipelines (GitHub: security hardening with OpenID Connect, checked on 2026-08-13):

  • repo:example-org/example-repo:ref:refs/heads/main for a push to a branch.
  • repo:example-org/example-repo:ref:refs/tags/v1.2.0 for a tag.
  • repo:example-org/example-repo:environment:production when the job declares a deployment environment.
  • repo:example-org/example-repo:pull_request for a pull request run.

Two conditions belong in every policy. Pinning aud stops a token minted for another service from being replayed at STS. Pinning the full sub with StringEquals keeps the role attached to one repository and one ref. A StringLike condition on a prefix such as repo:example-org/* grants the role to every repository in the organization, which turns a per pipeline credential into an organization wide one.

Tag deploys are the case where a wildcard is tempting, because tag names change every release. Prefer a deployment environment there: gate the job with environment: production, match repo:example-org/example-repo:environment:production, and let GitHub's required reviewers decide when the credential is reachable.

What each failure message means

The exchange fails in a small number of ways, and the message names the half that is wrong. Messages come from the credentials action and from AWS STS (aws-actions/configure-aws-credentials, AWS STS API reference).

Message you seeHalf that is wrongFix
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variableWorkflowAdd id-token: write to the job, and check that no other permissions block above it dropped the scope
Credentials could not be loaded, please check your action inputsWorkflowSame missing grant, or role-to-assume absent from the step inputs
Not authorized to perform sts:AssumeRoleWithWebIdentityTrust policyThe sub in the token differs from the condition. A release tag, a fork pull request, or a job in an environment produces a different subject than a push to a branch
Incorrect token audienceTrust policyThe aud condition and the audience the action requested disagree. Leave the default sts.amazonaws.com on both sides unless a second provider needs its own value
No OpenIDConnect provider found in your accountAWS accountThe identity provider was never created in the account holding the role
DurationSeconds exceeds the MaxSessionDuration set for this roleRoleLower role-duration-seconds on the step or raise the maximum session duration on the role

For the third row, print the subject the job actually presents before editing the policy again. The github context carries the same fields the token does, so echo "${{ github.ref }}" and echo "${{ github.repository }}" in a debug step reconstruct the string the policy has to match (GitHub: contexts).

How this differs from an instance profile on a BYOC runner

An instance profile attaches an IAM role to the EC2 instance itself, so every job that lands on that runner class reads the same credentials from instance metadata. The exchange attaches an identity to the workflow run instead, so authority follows the repository and the ref. Both are available on BYOC, where BYOC runs on AWS, GCP, and Azure and the Instance Profile ARN is a field on the custom runner configuration (instance profile setup).

runs-on labelWhere AWS credentials come fromWhat the grant is scoped to
warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB)The OIDC exchange inside the jobOne repository and one ref per role
warp-custom-<runner-name> (BYOC on AWS)An instance profile on the EC2 instance, the OIDC exchange, or bothThe instance profile covers every job on that runner class; the exchange stays per repository and ref
ubuntu-latest (GitHub-hosted)The OIDC exchange inside the jobOne repository and one ref per role

The two compose cleanly because of ordering in the AWS SDK credential chain. Environment variables are read before instance metadata, so a job that starts with instance profile credentials and then runs the exchange step uses the exchanged role for everything after that step (AWS SDK credential provider chain). A common split is baseline pulls through the instance profile and deployment authority through the exchange, so a compromised test job never reaches the deploy role.

Use the instance profile when the permission is a property of the machine class, such as reading a shared artifact bucket. Use the exchange when the permission is a property of the pipeline, such as pushing to production. The BYOC runner IAM answer covers the iam:PassRole grant that the profile path needs first.

What a security review records

The exchange removes one class of secret from the repository, and reviewers usually ask what happens to the rest. WarpBuild does not access or store build secrets; they stay in your source code repository and reach only the runner environment, which is destroyed with its encrypted volume when the build ends (runner security documentation). The SOC 2 Type 2 attestation covering Security, Availability, and Confidentiality is requested through trust.warpbuild.com. The secrets management guide covers what stays in repository, environment, and organization secrets after the AWS keys are gone.

Pricing does not gate a trial of any of this. Once authentication is settled, the guide to speeding up GitHub Actions covers the runner side of the same pipeline.

Why does the credential step fail with a missing ACTIONS_ID_TOKEN_REQUEST_URL variable?

The job does not hold the id-token: write grant. GitHub injects ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN only into jobs that request it, and any permissions block sets every scope it leaves out to none, so a workflow level block listing contents: read alone strips the grant from every job below it (GitHub: workflow syntax). Add id-token: write to the job that runs the exchange. The OIDC token exchange definition walks through what the token carries once the grant is in place.

Can one IAM role serve every repository in the organization?

It can, and that is the configuration to avoid. A StringLike condition on a subject prefix such as repo:example-org/* hands the role to every current and future repository in the organization, including one a contributor creates tomorrow. Pin the full subject with StringEquals and create one role per repository and deployment target, which keeps the blast radius of a compromised workflow to the resources that one pipeline touches. The secrets management guide covers the same blast radius question for the repository and organization secrets that sit next to this role.

Do I still need OIDC if my BYOC runner already carries an instance profile?

It depends on how the permission varies. An instance profile gives every job on that runner class the same AWS role, which fits baseline access such as pulling base images (instance profile setup). An OIDC exchange gives each workflow run an identity carrying the repository, branch, and environment, so authority can differ per repository and per branch on shared runners. The two compose, and credentials set by the exchange step take precedence over instance metadata for the rest of the job.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.