jonsully1.dev

Deploying AWS Infrastructure via GitHub Actions with Open ID Connect

John O'Sullivan
John O'Sullivan
Senior Full Stack Engineer
& DevOps Practitioner

📖 8-10 minute read
Perfect for your morning coffee break.

Table of Contents

  1. Introduction
  2. GitHub Repository
  3. Prerequisites
  4. Create an IAM assumable role for GitHub Actions
  5. Wrapping Up
  6. Further Reading

Introduction

When building pipelines that deploy infrastructure to AWS with GitHub Actions, the workflows need to authenticate with AWS in order to interact with cloud resources, i.e. pushing Docker images to ECR, provisioning S3 buckets or ACM certificates. The easiest way to do this is using the configure-aws-credentials action. But many engineers fall into the trap of using static AWS access keys.

On the surface, this seems quick and straightforward. But those access keys come with a lot of baggage: you have to manually create an IAM user, manage key rotation, store them securely as GitHub secrets, and hope no one accidentally leaks them. Worse, if they get compromised, you're stuck with a security headache.

That’s why AWS recommends using IAM roles and OpenID Connect (OIDC) instead when we attempt to create access keys against a user:

AWS OpenID Connect Workflow

Github Repository

If you prefer to jump straight to the code: github-actions-with-iam-assumable-role

Using IAM Roles and Temporary Credentials in GitHub Actions

IAM roles and OIDC allow GitHub Actions workflows to request temporary permissions by assuming specific IAM roles. The workflow essentially authenticates by saying: "I'm a GitHub Action workflow from repository A running in branch B - please grant me permissions to assume IAM role C so that I may make some infrastructure changes."

In this guide, I'll walk through how to easily implement IAM roles and temporary credentials for GitHub Actions workflows.

Prerequisites

Before beginning, you'll need:

  • An existing GitHub repository
  • Access to an AWS account with appropriate permissions
  • Some basic Terraform knowledge will be helpful

Create an IAM assumable role for GitHub Actions

Define the OIDC Provider

By defining an OIDC provider we are basically telling our AWS account that GitHub is one of the services that we can trust when it attempts to connect. It's like tell saying to the concierge at your building:

If someone shows up with a package labeled ‘sts.amazonaws.com’ from ‘token.actions.githubusercontent.com’, and their ID checks out, let them in.

resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
}

The url and client_id_list values can be found in the GitHub docs:

Adding the identity provider to AWS

There was a time we were required to included a thumbprint_list property too, however an optimisation in 2023 removed this.

Create an IAM assumable role

Building on the delivery analogy, an IAM assumable role is like a pre-authorised access pass that the concierge (AWS) can issue to the delivery person (GitHub Actions). The pass is temporary (no permanent AWS keys) and limited to certain areas of the building (only grants the permissions we wil define in Step 3).

We use the pre-built, community-tested iam-assumable-role-with-oidc (by AWS Community Hero, Anton Babenko) that handles the IAM complexity for us. No need to reinvent the wheel.

module "github_actions_assumable_role" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role-with-oidc"
  version = "5.33.1"

  create_role                    = true
  provider_url                   = "https://token.actions.githubusercontent.com"
  role_name                      = "github-dev-AssumableRole"
  oidc_fully_qualified_audiences = ["sts.amazonaws.com"]
  oidc_subjects_with_wildcards   = ["repo:your-repo-name:ref:refs/heads/*"]
}

To avoid duplicating values like the GitHub OIDC provider URL and audience, we'll define them as local variables and reuse them across both the OIDC provider and IAM role configuration:

locals {
  github_oidc_config = {
    provider_url = "https://token.actions.githubusercontent.com"
    audience     = "sts.amazonaws.com"
  }
}

# Create OIDC provider to trust GitHub's identity tokens
resource "aws_iam_openid_connect_provider" "github_actions" {
  url            = local.github_oidc_config.provider_url
  client_id_list = [local.github_oidc_config.audience]
}

# Create IAM role that GitHub Actions can assume
module "github_actions_role" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role-with-oidc"
  version = "5.33.1"

  create_role                    = true
  provider_url                   = local.github_oidc_config.provider_url
  role_name                      = "github-actions-role"
  oidc_fully_qualified_audiences = [local.github_oidc_config.audience]
  oidc_subjects_with_wildcards   = ["repo:your-org/your-repo:*"]  # can be used by any branch
}

Great, that's cleaner.

We’ve configured oidc_subjects_with_wildcards to allow any branch in the repository to assume this role.

For stricter security, instead use oidc_fully_qualified_subjects to restrict access to specific branches (e.g., only your protected main branch). This ensures the role can’t be assumed by untested code in feature branches:

oidc_fully_qualified_subjects = ["repo:your-org/your-repo:ref:refs/heads/main"]

Create and Attach an IAM Policy

Just like the access pass given to the delivery person restricts which floors they can enter, this IAM policy defines exactly what AWS resources GitHub Actions can access.

You’ll need to customise this policy based on the AWS resources that your workflows need access to. If you manage your terraform state via S3 and DynamoDB in your AWS account then at the absolute minimum you'll need the below configuration so that your workflow can read/update TF state between plan and apply stages.

resource "aws_iam_policy" "github_actions_assumable_role_policy" {
  name        = "github-actions-assumable-role-policy"
  description = "IAM policy for GitHub Actions assumable role"

  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      # S3 permissions for Terraform state bucket
      {
        Effect = "Allow",
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject",
          "s3:ListBucket"
        ],
        Resource = [
          "arn:aws:s3:::org-dev-infra-terraform-tfstate",
          "arn:aws:s3:::org-dev-infra-terraform-tfstate/*"
        ]
      },
      # DynamoDB permissions for state locking
      {
        Effect = "Allow",
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem",
          "dynamodb:DescribeTable"
        ],
        Resource = "arn:aws:dynamodb:eu-west-2:<your-aws-account-id>:table/org-dev-infra-tfstate"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "github_actions_assumable_role_policy_attachment" {
  role       = module.assumable_role.iam_role_name
  policy_arn = aws_iam_policy.github_actions_assumable_role_policy.arn
}

Output the Role ARN

Now that we've created our secure access pass (the IAM role), we are going to give GitHub Actions an ID (the ARN) to pass to AWS. Remember Step 1, where we defined the url and client_id_list values in the aws_iam_openid_connect_provider and we likened this to giving the conceirge a clear instruction to give our delivery person access:

If someone shows up with a package labeled ‘sts.amazonaws.com’ from ‘token.actions.githubusercontent.com’, and their ID checks out, let them in.

By providing GitHubActions with the ID (ARN) of the secure access pass (IAM role) our conceirge knows the delivery person has been pre-approved and exactly which pass to provide, speeding up the transation at the front desk.

output "iam_role_arn" {
    value = module.assumable_role.iam_role_arn
}

Provision the Role

Let's plan and apply the changes to our infra:

Plan

❯ terraform  plan -out=tfplan && terraform show tfplan

...

Plan: 4 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + iam_role_arn = (known after apply)

Apply

❯ terraform apply tfplan

...

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

iam_role_arn = "arn:aws:iam::<my-aws-account-id>:role/github-actions-role"

Verification

The assumable role has been provisioned so all that's left is to verify GitHub Actions can assume it using the following workflow:

name: Verify IAM Role

on:
  workflow_dispatch:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

jobs:
  verify-iam-role:
    runs-on: ubuntu-latest
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-role
          aws-region: eu-west-2
      - name: Verify IAM Role
        run: aws sts get-caller-identity

Don't forget to add your AWS account ID to the repository actions secrets so that its available in the pipeline:

Add Account ID to Repository Secrets

A push to main triggers the pipeline above and we can confirm we have assumed the role:

Verify IAM Assumable Role via GitHub Actions

Wrapping Up

Setting up GitHub Actions to use temporary AWS credentials through IAM roles is one of those rare improvements that actually makes life easier while boosting security. Instead of wrestling with rotating access keys, you get a system that automatically issues just-in-time permissions exactly when needed. The whole setup takes maybe fifteen minutes with Terraform, and once it's working, you can easily reuse it across every environment and AWS account you manage.

You'll find working examples in the github-actions-with-iam-assumable-role repo if you want to hit the ground running.

At the end of the day, this approach gives you airtight credentials management without the maintenance headaches, letting your team focus on building instead of security administration.

Further Reading