CI/CD Pipeline Orchestration

Automating Terraform State Management via GitHub Actions OIDC Authentication

Around a year ago, I made the mistake of pushing an AWS access key to a publicly available repository. The access key belonged to an extremely old IAM user that had full admin access (don’t ask how that happened!). Thankfully, I was able to identify and remedy this occurrence within minutes by rotating that access key, but it meant that I had to modify all of my CI pipelines by hand. After going through that experience, I can no longer use static credentials.

Since then, I have converted all my Terraform workflows to utilize GitHub Actions OIDC authentication. While it’s not magic, it did require me to connect a few pieces to complete the workflow. This post serves as the complete playbook that I wished someone would have given me at the time.

Quick Summary

  • Do away with static IAM keys and replace them with ephemeral tokens via OIDC
  • Create an S3 backend secured with DynamoDB for State Locking
  • Create an IAM role with a trust policy that validates your Repo and Branch
  • Create a pipeline for commenting on plans that include security scans through tfsec
  • Manage the expiration of AWS tokens for huge terraform apply runs.

Understanding Dynamic Credentials GitHub Actions for Infrastructure

Shifting away from static keys and implementing an ephemeral access method to the cloud limits blast radius when sensitive data is compromised.

The Security Risks of Long‑Lived IAM Users

An IAM User with permanent access keys lives indefinitely. If that access key gets exposed through a misplaced .env file or is logged, attackers can use the keys to enumerate your assets at their leisure. Rotating the access keys breaks every downstream pipeline.I’ve witnessed teams attempt to address workflow failures by temporarily disabling offending users within CI pipelines until they realize that disabling 50% of the jobs caused them unintentional disruption.

Instead, there’s a second option: Using GitHub Actions Dynamic Credentials. Dynamic credentials are tokens associated with a single workflow job and automatically expire when the job terminates (if a job fails, the credential will not remain). Therefore, there is no longer any need to rotate keys, or clean up after using keys.

How the Github OIDC Identity Provider AWS Trust Works

Visualize the following: GitHub has integrated an OIDC identity provider into its GitHub Actions runtime environment. Each job is granted a dynamically generated JWT (JSON Web Token) that contains several attributes (like repository name, branch, and actor). AWS IAM trusts this OIDC identity provider, so rather than using an Access Key, you exchange this JWT via the AWS SDK for JavaScript using AssumeRoleWithWebIdentity and can obtain an STS Token (Security Token Service Token).

Via the AWS IAM Role Trust Policy, you can restrict which repositories and branches can assume your roles by specifying StringLike conditions within your trust policy. For the mechanics of how this functions, see the AWS documentation on web identity federation; however, the primary concept is straightforward – No secrets are stored, instead, a secure trust relationship is established between GitHub Actions and AWS IAM.

Prerequisites for a Secure TFState S3 Backend GitHub Actions Workflow

To utilize the AWS-S3-Backend-HTN Terraform configuration (which does not depend upon local files), you need to provide an S3 Bucket with a DynamoDB Lock Table. S3 + DynamoDB Lock Table is the industry standard for backend storage and integrates well with the OIDC flow.

Creating the S3 Bucket and DynamoDB Lock Table

To create the backend resources, you must create them outside as they cannot be provisioned until after the CI/CD Pipeline(s) are generated. I prefer to create these resources with a purpose-built Terraform Configuration file that has an associated AMI User created specifically to boot-strap them. This Terraform Configuration is typically created in an external repository that does not contain any of the Terraform Code being secured.

resource "aws_s3_bucket" "tfstate" {
  bucket = "my-org-tfstate-backend-2025"
}

resource "aws_s3_bucket_versioning" "tfstate_versioning" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate_encryption" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_dynamodb_table" "tfstate_lock" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

The above block of code can be used to create an encrypted bucket that will use versioning to keep track of the objects’ changes and a DynamoDB table that has a LockID as its Partition Key. In addition to these resources, the DynamoDB table is used to ensure that two or more instances of a pipeline do not try to use the same state file concurrently.

Configuring Least Privilege Backend IAM Policies

The only thing the IAM Role used by your pipeline needs access to is the bucket and DynamoDB table you just created. There’s absolutely no need for it to have any other access. There are IAM Policies created that will allow the IAM Role to execute the following actions on the new resources: s3:GetObjects3:PutObject, and s3:ListBucket on the state bucket; and dynamodb:GetItemdynamodb:PutItem, and dynamodb:DeleteItem on the Lock Table. That’s all there is to it – no wildcard resources, and no s3:* action permissions. Once you create the IAM Policy, attach it to the OIDC Role that is going to be created.

Configuring GitHub Actions Terraform OIDC AWS Authentication

Why I Ultimately Chose This Route

The initial thought was to store and use the AWS Access Keys and Secret Keys as GitHub Secrets. This method works until a developer introduces a debug line in the workflow that outputs the environment variables. It also becomes very cumbersome to have to manage Access Keys and Secret Keys if you support 30 repositories due to the need to rotate them during a maintenance window. With OIDC, the tokens are created in real-time and expire, so there is nothing sensitive stored in the repository settings. The tradeoff is that you will need to configure the IAM Role to have OIDC trust once per AWS account, but from that point forward you will simply add a couple of lines to your workflow YAML file.

Deploying the OIDC Provider in AWS IAM

The first thing you will need to do is tell AWS that you want to use the OIDC Endpoint for GitHub. I will use the AWS CLI to do this because it is quick.

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

The intermediary certificate GitHub employs has the thumbprint as its fingerprint, while the GitHub OIDC documentation revises it as required. This command will provide an ARN to include in the trust policy.

Defining the IAM Role and Trust Policy

Next, you will configure the IAM role which will be adopted by your GitHub Actions workflows. The following trust policy permits any branch of a specific repo to obtain a token, but you could limit access to just main or a certain tag.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"
        },
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

You define the repository name and branch in the sub claim. If you need to restrict the token based on the environment, change the value in the pattern to repo:my-org/my-repo:ref:refs/heads/main. The aud verification ensures the token was intended to be used with AWS STS.

Building the CI/CD Pipeline with HashiCorp Setup-Terraform Action

Now comes the enjoyable part of combining everything into a workflow that executes Terraform, comments on a pull request (PR), and scans for misconfigurations.

Integrating TFSec Integration Pipeline for Pre‑Flight Checks

Prior to anything happening, I run tfsec as an initial step. If tfsec detects that an AWS security group is open to 0.0.0.0/0, it fails the job and no plan is created. This illustrates the tfsec integration pipeline performing its responsibilities upfront. The tfsec action can identify concerns directly on the PR diff, but I choose to impose a hard stop.

Writing the Workflow YAML for AWS Credentials

What you will find below is the main OIDC authentication step. It is through the action aws-actions/configure-aws-credentials that the GitHub token is exchanged with the STS session token.

- name: Configure AWS credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/my-oidc-role
    aws-region: us-east-1
    role-duration-seconds: 900

You do not need an access key ID nor secret access key.Behind the scenes, the action reads the env var ACTIONS_ID_TOKEN_REQUEST_TOKEN. I only adjust role-duration-seconds when I’m certain an apply will be lengthy.

The hashicorp set up terraform action slot comes in earlier in the process. I utilize it to set up Terraform and, if desired, employ a wrapper that manages state around the CLI.

- name: Setup Terraform
  uses: hashicorp/setup-terraform@v3
  with:
    terraform_version: "1.9.8"

Executing Terraform Plan GitHub Actions PR Comment

When a plan output is posted as a PR comment, no one has to sift through CI logs to find out what changes were made to the infrastructure. The step utilizes github-script to take the usage information from the prior terraform plan step and post it directly in the conversation.

- name: Add plan to PR
  if: github.event_name == 'pull_request'
  uses: actions/github-script@v7
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
      const plan = process.env.PLAN_OUTPUT;
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: '### Terraform Plan\n```hcl\n' + plan + '\n```'
      });
  env:
    PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}

The terraform plan GitHub Actions PR comment example in action. Reviewers can see exactly what will be created, changed or destroyed without leaving the GitHub interface.

Handling AWS Token Expiry During Long‑Running Terraform Applies

It’s not a poorly-designed plan step that creates the problem. The actual pain point is when the terraform apply operation lasts longer than an hour, and the default one-hour token life expires.

Debugging “ExpiredToken” STS Errors

When your apply fails due to the “ExpiredToken” error, check your CloudTrail console. The event for the AssumeRoleWithWebIdentity operation will be similar to this:

"eventTime": "2025-05-02T12:15:30Z",
"eventSource": "sts.amazonaws.com",
"eventName": "AssumeRoleWithWebIdentity",
"errorCode": "ExpiredToken"  <-- Right here
"errorMessage": "The token used has expired and is no longer valid."
"requestParameters": {
    "roleArn": "arn:aws:iam::123456789012:role/my-oidc-role",
    "durationSeconds": 3600
}

The default expiration time of 3600 seconds isn’t always sufficient. Depending on how many hundreds of data sources Terraform is refreshing or how many resources Terraform is deleting in one gigantic VPC, it’s possible for the operation to take longer than an hour.

Bumping the Session Duration in the Role Assumption

There are two locations where the session duration can be controlled. The first place is the maximum session duration set on the IAM role. Make sure to set the maximum session duration for your OIDC Role to 43200 Seconds (12 hours) via the AWS Console: navigate to IAM > Roles > my-oidc-role and click Edit under Maximum session duration, then set your workflow with a matching value.

- name: Configure AWS credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/my-oidc-role
    aws-region: us-east-1
    role-duration-seconds: 21600

Do not set the maximum session duration if it is not needed. For most scenarios, 3-6 hours will cover the worst case; thus after that timeout, you will eliminate the “ExpiredToken” issues.

Frequently Asked Questions

Why is my GitHub Actions workflow failing with “Not authorized to perform sts:AssumeRoleWithWebIdentity”?

This failure is caused by a mismatch between the sub statement in your trust policy and the actual value of the JWT that GitHub is sending to you. Please confirm that you have entered the correct repo name into your workflow and not accidentally running from a fork that uses a different claim format. In the case of a fork PR, the sub statement will contain the repo owner’s name rather than yours. You will need to restrict the trigger to only accept pull_request_target events or accept the fact that you cannot use OIDC on forks unless you change the sub condition.

How can I restrict OIDC authentication to a specific environment or Git tag?

To restrict OIDC to a specific git tag, you can set a pattern on the sub claim to be repo:my-org/my-repo:ref:refs/tags/v* To restrict OIDC to a specific environment, you can use a workflow environment in combination with a condition based on the environment claim using this syntax: token.actions.githubusercontent.com:environment: production, the trust policy must explicitly list the environment names.

Does the hashicorp setup-terraform action natively support AWS OIDC tokens?

No, this does not matter because it will just install Terraform; AWS OIDC token exchange will occur via aws-actions/configure-aws-credentials. Therefore, there is no need for Terraform to natively support OIDC because both CLI and Terraform use the same environmental variables (AWS_ACCESS_KEY_IDAWS_SESSION_TOKEN) set up by the configure-credentials step. Therefore, the setup-terraform action will work seamlessly when running OIDC.

And finally, that is all the wiring together. As you have now removed the long-lived IAM users and moved them to OIDC, you can now have peace of mind because you will no longer be dealing with key rotation panics and your CI pipeline will post up terraform plans directly to the PR and tfsec will allow you to add the gates. This is a common pattern for building infrastructure, and as you go through this process of building up infrastructure, it will seem like a lot of work but will be rewarded in every sprint.

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button