Back to blog

Delivery Workflows

Deploy to AWS from GitHub Actions with OIDC, no static keys

How to securely deploy to AWS from GitHub Actions using OIDC

September 8, 2025Platform Engineering6 min read

GitHub Actions make CI/CD straightforward, but securely accessing AWS from runners requires care. Historically that often meant storing long-lived AWS access keys as GitHub secrets, which creates obvious security and rotation problems. With OpenID Connect (OIDC), GitHub Actions can instead assume AWS roles using short-lived credentials.

In this post, we will walk through how GitHub OIDC works with AWS, how to set it up, and how to integrate it with OpenTofu or Terraform for infrastructure deployment. Sources: GitHub OIDC in AWS, AWS IAM OIDC providers.

Understanding the OIDC Flow

At a high level, the GitHub to AWS flow looks like this:

  1. GitHub Actions generates a signed token for the workflow run.
  2. AWS validates that token against the configured OIDC provider.
  3. AWS STS checks the IAM role trust policy.
  4. If the claims match, AWS issues temporary credentials.
  5. The workflow uses those credentials to access AWS resources.

That removes the need to store static AWS keys in GitHub.

Setting Up the AWS OIDC Identity Provider

First, create an OIDC identity provider in AWS that trusts GitHub’s issuer:

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1"
  ]

  tags = {
    Name = "GitHub OIDC Provider"
  }
}

If you build this manually with the AWS CLI, make sure the provider URL and audience match the GitHub OIDC documentation for AWS.

Creating the IAM Role

The next step is an IAM role that GitHub Actions can assume. The most important part is the trust policy.

{
  "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:your-org/your-repo:*"
        }
      }
    }
  ]
}

A Terraform or OpenTofu role resource would follow the same pattern.

Understanding JWT Claims and Conditions

GitHub’s OIDC token includes several useful claims:

  • aud for the audience
  • sub for the repository and ref context
  • repository
  • repository_owner
  • ref
  • actor

Those claims let you narrow trust policies so only the right repositories, branches, or pull requests can assume the role.

For example:

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:repository_owner": "your-org"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:your-org/*:*"
    }
  }
}

GitHub Actions Workflow Implementation

The workflow needs id-token: write so GitHub can mint an OIDC token for the run:

name: Deploy Infrastructure

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  plan:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/OpenTofuExecutionRole
          role-session-name: github-actions-opentofu-session
          aws-region: eu-west-2

      - name: Validate and Plan
        uses: coresolutionsltd/tf-github-action@v1.0.0
        with:
          workdir: ./infra
          env: prod
          steps: validate plan

You can extend the same pattern for apply, multi-environment workflows, or cross-account deployment.

Multi-Environment and Cross-Account Patterns

For multiple environments, use different roles:

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ github.ref == 'refs/heads/main' &&
      'arn:aws:iam::123456789012:role/ProdOpenTofuRole' ||
      'arn:aws:iam::123456789012:role/DevOpenTofuRole' }}
    role-session-name: ${{ github.run_id }}-${{ github.run_attempt }}
    aws-region: eu-west-2

For multiple AWS accounts, a matrix strategy works well:

strategy:
  matrix:
    environment: [dev, staging, prod]
    include:
      - environment: dev
        account_id: '111111111111'
        region: 'eu-west-1'
      - environment: staging
        account_id: '222222222222'
        region: 'eu-west-2'
      - environment: prod
        account_id: '333333333333'
        region: 'eu-west-2'

Security Best Practices

1. Principle of Least Privilege

Grant only the permissions the workflow actually needs.

2. Branch and Repository Restrictions

Use specific subject conditions so only the right repositories and branches can assume the role.

3. Session Duration Limits

Keep role sessions as short as practical:

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/OpenTofuExecutionRole
    role-session-name: session
    aws-region: eu-west-2
    role-duration-seconds: 1800

Troubleshooting Common Issues

“No OpenIDConnect provider found”

This usually means the IAM OIDC provider was not created, was created in the wrong account, or does not match GitHub’s issuer URL.

“AssumeRoleWithWebIdentity is not authorised”

This almost always points to a trust policy mismatch. Common causes are:

  • the wrong repository name in the sub claim
  • missing or incorrect aud
  • branch restrictions that do not match the current ref

Insufficient Permissions

If the role assumption succeeds but the workflow still fails, inspect CloudTrail or the action logs to identify which AWS actions are missing.

The failure you will hit first

Nearly every first attempt fails the same way: the sub condition in the trust policy does not match the ref the workflow actually runs from. Print the claim, compare it against the condition character by character, and resist widening the IAM policy until they match. If the role assumption succeeds and the workflow still fails, that is a different problem and CloudTrail will name the action that was denied.