← All posts
· 4 min read ·
CI/CDSecurityAWSGitHub ActionsOIDCIAM

OIDC in GitHub Actions: The Right Way to Authenticate to AWS in 2026

Long-lived AWS access keys in GitHub secrets are a supply chain risk waiting to happen. OIDC-based role assumption eliminates them entirely - here's how to set it up correctly and harden it against token abuse.

Diagram showing OIDC token exchange between GitHub Actions and AWS STS

Rotating leaked AWS credentials is a painful, stressful exercise. The cause is usually the same: a long-lived AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored in GitHub Secrets, forgotten for years, scoped too broadly. OIDC-based authentication removes the credential entirely - there is nothing to leak.

This is not new technology. GitHub added OIDC support in 2021. AWS added GitHub as a supported provider around the same time. But misconfigured OIDC trust policies create their own class of vulnerability, and most tutorials skip past the hardening. Let’s do it properly.

How GitHub Actions OIDC Works

When a workflow job runs, GitHub’s OIDC provider issues a short-lived JSON Web Token (JWT) signed with GitHub’s private key. The workflow exchanges this token with AWS STS via AssumeRoleWithWebIdentity. AWS validates the token signature against GitHub’s public JWKS endpoint and evaluates the IAM trust policy. If it matches, STS returns temporary credentials scoped to the role - valid for the duration of the job, then gone.

GitHub Runner → GitHub OIDC Provider → JWT
JWT → AWS STS AssumeRoleWithWebIdentity
STS validates JWT signature + trust policy conditions
STS returns temporary credentials (valid 1 hour)
Runner uses credentials → credentials expire

No stored secret. No rotation required. Credentials can’t be reused after the job ends.

Setting Up the IAM OIDC Provider

First, add GitHub as a trusted OIDC provider in your AWS account. This only needs to be done once per account.

Terraform:

data "tls_certificate" "github_actions" {
    url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}

resource "aws_iam_openid_connect_provider" "github_actions" {
    url             = "https://token.actions.githubusercontent.com"
    client_id_list  = ["sts.amazonaws.com"]
    thumbprint_list = [data.tls_certificate.github_actions.certificates[0].sha1_fingerprint]
}

AWS CLI (if not using Terraform):

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

Creating the IAM Role with a Hardened Trust Policy

This is where most guides go wrong. A trust policy that only checks the audience (sts.amazonaws.com) lets any GitHub repository in the world assume your role if they know your account ID and role ARN.

Insecure (don’t use this):

{
    "Condition": {
        "StringEquals": {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        }
    }
}

Correct - lock to your org and repo:

data "aws_iam_policy_document" "github_actions_trust" {
    statement {
        actions = ["sts:AssumeRoleWithWebIdentity"]
        principals {
            type        = "Federated"
            identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
        }
        condition {
            test     = "StringEquals"
            variable = "token.actions.githubusercontent.com:aud"
            values   = ["sts.amazonaws.com"]
        }
        condition {
            test     = "StringLike"
            variable = "token.actions.githubusercontent.com:sub"
            # Lock to specific repo and branch
            values   = ["repo:your-org/your-repo:ref:refs/heads/main"]
        }
    }
}

The sub claim is where you enforce scope. Common patterns:

# Only main branch
repo:org/repo:ref:refs/heads/main

# Any branch (broader  -  use only if needed)
repo:org/repo:ref:refs/heads/*

# Specific environment (requires environment protection rules)
repo:org/repo:environment:production

# Pull requests (for read-only roles)
repo:org/repo:pull_request

For production deployments, use the environment constraint and configure environment protection rules in GitHub. This requires a reviewer approval before the job can run.

The Workflow

name: Deploy

on:
    push:
        branches: [main]

permissions: {}

jobs:
    deploy:
        runs-on: ubuntu-latest
        permissions:
            contents: read
            id-token: write  # Required for OIDC

        steps:
            - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

            - name: Configure AWS credentials
              uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v4
              with:
                  role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
                  aws-region: us-east-1
                  role-session-name: github-deploy-${{ github.run_id }}

            - name: Deploy
              run: aws s3 sync ./dist s3://${{ secrets.S3_BUCKET }}

Key details:

  • permissions: {} at workflow level, grant only id-token: write at job level
  • Pin action SHAs, not version tags (more on this below)
  • role-session-name with the run ID makes CloudTrail logs traceable per workflow run

Scoping the Role’s Permissions

The role should have the minimum permissions needed for the specific job. A deployment role is not a developer role.

resource "aws_iam_role_policy" "deploy" {
    name = "deploy-policy"
    role = aws_iam_role.github_actions.id

    policy = jsonencode({
        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/*",
                ]
            },
            {
                Effect = "Allow"
                Action = ["cloudfront:CreateInvalidation"]
                Resource = "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
            }
        ]
    })
}

Never attach AdministratorAccess or PowerUserAccess to a GitHub Actions role. If a workflow is compromised, the blast radius is limited to what the role can do.

Auditing Existing Credentials

To find any remaining long-lived access keys in your GitHub organisation:

# List all org secrets (requires admin scope)
gh secret list --org your-org

# For each repo, check if AWS_ACCESS_KEY_ID exists
gh api /orgs/your-org/repos --paginate --jq '.[].name' | \
    xargs -I{} gh secret list --repo your-org/{}

For actual key rotation, use IAM Access Analyzer or the AWS Credential Report to identify all active access keys and when they were last used.

The Payoff

After migrating to OIDC:

  • No credentials to rotate, audit, or accidentally commit
  • CloudTrail logs show exactly which workflow run assumed the role
  • A compromised workflow run gets credentials valid for one job, not forever
  • You can revoke access by modifying the IAM trust policy, not hunting down secrets in every repo

The setup takes about 30 minutes the first time. The ongoing maintenance cost is essentially zero.

← All posts