Skip to content
Cloud & Infra Intermediate Tutorial

Replace Long-Lived AWS Credentials with GitHub Actions OIDC

Configure IAM and GitHub Actions to exchange short-lived, automatically-expiring AWS credentials on every workflow run — no stored access keys, no rotation toil.

Emeka Okafor
Emeka Okafor
Security Editor · Jun 15, 2026 · 6 min read

What You'll Build

You'll configure AWS IAM and GitHub Actions to use OpenID Connect (OIDC) federation, so your workflow requests short-lived AWS credentials on every run instead of reading a stored AWS_ACCESS_KEY_ID secret. Zero long-lived keys, zero rotation toil.

Prerequisites

  • AWS account with permissions to create IAM OIDC providers and roles
  • AWS CLI v2 (aws --versionaws-cli/2.x) installed and configured with credentials
  • A GitHub repository (org or personal) where you control workflow files and Settings
  • Familiarity with IAM trust policies and GitHub Actions YAML syntax

OS note: All CLI commands run identically on macOS, Linux, and WSL2. Native Windows Command Prompt users should use AWS CloudShell or WSL2.

Step 1: Register GitHub's OIDC Provider in IAM

AWS must trust tokens issued by GitHub's OIDC endpoint. Run this once per AWS account—it's shared across all repos.

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

Thumbprint note: As of late 2023, AWS validates GitHub OIDC tokens against the provider's root CA rather than this thumbprint value. The parameter is still required by the API but is not used for verification for GitHub's provider. See the AWS thumbprint docs for details.

The command returns an ARN like arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com. Save your Account ID—you'll need it next.

If you get EntityAlreadyExistsException, the provider already exists. Skip ahead.

Step 2: Create a Scoped IAM Role

2a. Write the trust policy

Save this as trust-policy.json, replacing ACCOUNT_ID, MY-ORG, and MY-REPO:

{
  "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"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:MY-ORG/MY-REPO:ref:refs/heads/main"
        }
      }
    }
  ]
}

Use the narrowest sub claim scope your workflow requires:

Condition value Who can assume
repo:ORG/REPO:ref:refs/heads/main main branch pushes only
repo:ORG/REPO:environment:production production environment jobs only
repo:ORG/REPO:pull_request Pull request events only
repo:ORG/REPO:* Any event in the repo (broadest—avoid in prod)

2b. Create the role

aws iam create-role \
  --role-name GitHubActions-Deploy \
  --assume-role-policy-document file://trust-policy.json \
  --description "Assumed by GitHub Actions via OIDC"

2c. Attach a permissions policy

Attach only what the workflow needs. This example grants read-only S3 access:

aws iam attach-role-policy \
  --role-name GitHubActions-Deploy \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

For real deployments, write a least-privilege custom policy. Never attach AdministratorAccess.

Record the role ARN from the create-role output: arn:aws:iam::ACCOUNT_ID:role/GitHubActions-Deploy.

Step 3: Update Your GitHub Actions Workflow

Two required changes: grant id-token: write permission and use aws-actions/configure-aws-credentials.

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required: allows the workflow to request an OIDC token
  contents: read    # Required: allows actions/checkout to fetch the repo

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

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/GitHubActions-Deploy
          aws-region: us-east-1

      - name: Smoke test — confirm assumed identity
        run: aws sts get-caller-identity

Remove any AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY entries from GitHub Secrets and any env: blocks that reference them. They are no longer needed.

Scope tip: Place the permissions block at the job level if other jobs in the same workflow file should not receive this grant.

Verify It Works

Push to main (or run the workflow manually via Actions → Run workflow). The aws sts get-caller-identity step should print:

{
    "UserId": "AROA...:GitHubActions-Deploy",
    "Account": "123456789012",
    "Arn": "arn:aws:iam::123456789012:assumed-role/GitHubActions-Deploy/GitHubActions-Deploy"
}

The assumed-role in the ARN confirms the workflow runs as the IAM role, not a long-lived IAM user. Credentials expire automatically after one hour (the default STS session duration).

Troubleshooting

Not authorized to perform sts:AssumeRoleWithWebIdentity The sub claim in the OIDC token didn't match your trust policy condition. Inspect the actual claim by adding this step temporarily (requires id-token: write and jq, which is pre-installed on ubuntu-latest). The JWT payload is base64url-encoded, so tr converts it to standard base64 before decoding:

curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" \
  | jq -r '.value' | cut -d. -f2 | tr -- '-_' '+/' | base64 -d 2>/dev/null | jq -r .sub

Compare the output against your StringLike condition. Branch names, case sensitivity, and wildcard placement are common mismatches.

id-token permission has not been granted The permissions block is missing or id-token is set to read. It must be exactly id-token: write.

EntityAlreadyExistsException on provider creation The OIDC provider already exists in this AWS account—one provider serves all repos. Skip Step 1.

Role assumes but downstream AWS calls are denied The permissions policy is too narrow. Check CloudTrail (aws cloudtrail lookup-events) for the denied action and add it to your permissions policy. Don't widen the trust policy—the problem is permissions, not trust.

Next Steps

  • Environments: Use GitHub Environments with required reviewers and map each to a separate IAM role with different permissions scopes.
  • Multiple repos: Use StringLike with an org-level wildcard (repo:MY-ORG/*) carefully, or create per-repo roles for fine-grained attribution.
  • Audit trail: AWS CloudTrail logs every AssumeRoleWithWebIdentity event; the sourceIdentity field captures the sub claim so you can trace which repo triggered each call.
  • Infrastructure as code: The AWS Terraform provider and AWS CDK both support OIDC provider and role resources natively—manage this setup the same way you manage the rest of your infrastructure.
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading