cd ../blog
terraformdevopsiacawscicdgithub

Eliminating Static AWS Credentials in GitHub Actions with OIDC

January 5, 202613 min read

Part 1 of 3 in the "Securing CI/CD Pipelines with OIDC" series. This is a 2026 Rewrite of my 2023 Article, "Using OIDC to secure your CI/CD Pipelines - No more long lived access keys!"

Static credentials in CI/CD pipelines are a liability. They don't expire, they're difficult to rotate, and when they leak; Whether that be through a misconfigured workflow, a careless commit, or a compromised dependency, attackers get persistent access until someone notices.

There's a better approach - OpenID Connect (OIDC) lets GitHub Actions authenticate to AWS without any stored secrets. Each workflow run gets short-lived credentials that expire when the job ends. No keys to rotate, no secrets to leak, and a clear audit trail tying every AWS action to a specific workflow run.

By the end of this post, you'll have a GitHub Actions workflow authenticating to AWS using OIDC, with all infrastructure defined in Terraform. We'll start with a minimal proof-of-concept, then extend it to a real-world example: building AMIs with Packer.

Table of Contents

  1. Why OIDC Over Access Keys
  2. How OIDC Authentication Works
  3. Prerequisites
  4. Implementation: The Minimal Setup
  5. Real-World Example: Building AMIs with Packer
  6. Security Considerations
  7. Summary

Why OIDC Over Access Keys

Access keys have served us well, but they come with operational baggage:

A single leak compromises everything. If credentials appear in logs, get committed to a repository, or end up in a Slack message, attackers have full access until the key is revoked. Detection often takes days or weeks.

Audit trails are murky. When multiple workflows share the same access key, distinguishing which workflow performed which action becomes difficult.

OIDC addresses all of these:

Credentials are ephemeral. Each workflow run receives credentials that last only for the duration of the job. There's nothing to rotate because nothing persists.

Trust is scoped precisely. You can restrict authentication to specific repositories, branches, or GitHub environments. A workflow in a feature branch can be denied access that the main branch has.

No secrets to manage. The authentication flow uses cryptographic verification rather than shared secrets. There's nothing stored in GitHub that an attacker could extract.

Clear audit trails. Each token carries claims identifying the exact repository, branch, workflow, and run. AWS CloudTrail logs show precisely which workflow performed each action.


How OIDC Authentication Works

The flow involves five steps, but the complexity is front-loaded into a one-time setup. Once configured, authentication happens automatically on every workflow run.

  1. Establish trust. You create an IAM OIDC Identity Provider in AWS that trusts GitHub's OIDC endpoint. This is a one-time configuration.
  2. Define permissions. You create an IAM role with a trust policy specifying which GitHub repositories (and optionally which branches or environments) can assume it. Standard IAM policies control what the role can do.
  3. Workflow requests a token. When a workflow runs, it requests a signed JWT from GitHub's OIDC provider. This token contains claims about the workflow: the repository, branch, actor, and other metadata.
  4. Workflow presents the token to AWS. Using the aws-actions/configure-aws-credentials action, the workflow calls sts:AssumeRoleWithWebIdentity, presenting the JWT.
  5. AWS validates and issues credentials. AWS verifies the JWT signature against GitHub's public keys, checks that the claims match the role's trust policy, and issues short-lived credentials. These credentials work like any other AWS credentials but expire when the job ends.

OIDC Diagram


Prerequisites

Before starting, ensure you have:

  • AWS account with permissions to create IAM resources (IAM Identity Providers, Roles, Policies)
  • GitHub repository where you'll run workflows (can be empty initially)
  • Terraform >= 1.4 installed locally
  • AWS CLI configured with credentials for your account

The implementation takes roughly 20 minutes for the minimal setup, or 40 minutes if you follow through to the Packer example.


Implementation: The Minimal Setup

We'll start with the simplest possible configuration: a workflow that authenticates via OIDC and runs aws sts get-caller-identity. This validates the plumbing works before we add complexity.

Project Structure

├── providers.tf
├── variables.tf
├── oidc.tf
├── outputs.tf
└── .github/
    └── workflows/
        └── test-oidc.yml

Terraform Configuration

providers.tf

terraform {
  required_version = ">= 1.4.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variables.tf

variable "aws_region" {
  type        = string
  description = "AWS region for resources"
  default     = "us-east-1"
}

variable "github_org" {
  type        = string
  description = "GitHub organisation or username"
}

variable "github_repo" {
  type        = string
  description = "GitHub repository name"
}

variable "role_name" {
  type        = string
  description = "Name for the IAM role"
  default     = "github-actions-oidc"
}

oidc.tf

# IAM OIDC Identity Provider
# This establishes trust between AWS and GitHub's OIDC endpoint.
# You only need one of these per AWS account, regardless of how many
# repositories or roles use OIDC authentication.

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

# IAM Role for GitHub Actions
# The trust policy controls which repositories can assume this role.

resource "aws_iam_role" "github_actions" {
  name = var.role_name

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
          }
        }
      }
    ]
  })
}

# Minimal policy for testing - allows the role to verify its own identity
resource "aws_iam_role_policy" "sts_get_caller_identity" {
  name = "sts-get-caller-identity"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "sts:GetCallerIdentity"
        Resource = "*"
      }
    ]
  })
}

A few things to note about the trust policy:

The thumbprint. AWS now validates GitHub's OIDC provider certificates automatically. The thumbprint value ffffffffffffffffffffffffffffffffffffffff is a placeholder that satisfies the API requirement without affecting validation. Earlier documentation recommended hardcoding GitHub's actual thumbprint**, **but this caused issues when GitHub rotated certificates.

The subject condition. The sub claim format is repo:{owner}/{repo}:{context}. Using repo:${var.github_org}/${var.github_repo}:* allows any workflow context (branches, tags, pull requests) from that repository. We'll tighten this later.

outputs.tf

output "role_arn" {
  description = "ARN of the IAM role for GitHub Actions"
  value       = aws_iam_role.github_actions.arn
}

output "oidc_provider_arn" {
  description = "ARN of the OIDC provider"
  value       = aws_iam_openid_connect_provider.github.arn
}

Deploy the Infrastructure

terraform init
terraform plan -var="github_org=your-username" -var="github_repo=your-repo"
terraform apply -var="github_org=your-username" -var="github_repo=your-repo"

Note the role_arn output—you'll need it for the workflow.

GitHub Actions Workflow

.github/workflows/test-oidc.yml

name: Test OIDC Authentication

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  test-auth:
    runs-on: ubuntu-latest
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-oidc
          aws-region: us-east-1

      - name: Verify authentication
        run: aws sts get-caller-identity

Replace the role-to-assume value with the ARN from your Terraform output.

The permissions block is critical. Without id-token: write, the workflow cannot request a JWT from GitHub's OIDC provider, and authentication will fail silently. The contents: read permission allows the workflow to check out code if needed.

Validation

Push the workflow to your repository. If everything is configured correctly, the "Verify authentication" step will output something like:

{
    "UserId": "AROA3XFRBF535EXAMPLE:botocore-session-1234567890",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/github-actions-oidc/botocore-session-1234567890"
}

The role name in the ARN confirms OIDC authentication is working.

Troubleshooting Common Failures

"Not authorized to perform sts:AssumeRoleWithWebIdentity"

The trust policy conditions don't match the JWT claims. Common causes:

  • Repository name mismatch (check for typos, case sensitivity)
  • The workflow is running on a branch or context not permitted by the sub condition
  • The aud claim doesn't match (should be sts.amazonaws.com)

Debug by checking the workflow's OIDC token claims. Add this step before the AWS configuration:

- name: Debug OIDC claims
  run: |
    TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
    echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .

"No OpenIDConnect provider found in account"

The OIDC provider wasn't created, or the ARN in the trust policy is incorrect. Verify the provider exists:

aws iam list-open-id-connect-providers

Workflow hangs or times out

Usually indicates the permissions block is missing or incorrect. The workflow can't request a token, so the AWS action waits indefinitely.

Scoping Trust Appropriately

The minimal setup allows any workflow context from your repository. For production use, tighten this based on your needs.

Main branch only

StringEquals = {
  "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"
}

Specific GitHub environment

StringEquals = {
  "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:environment:production"
}

This requires configuring a GitHub environment in your repository settings and referencing it in your workflow:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      # ...

Multiple contexts

StringLike = {
  "token.actions.githubusercontent.com:sub" = [
    "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main",
    "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/release/*"
  ]
}

Start with the most restrictive scope that meets your needs. You can always widen it later.


Real-World Example: Building AMIs with Packer

With OIDC authentication working, let's apply it to something practical: building Amazon Machine Images using Packer, configured via Ansible.

This example demonstrates:

  • Appropriate IAM permissions for Packer builds
  • Retrieving secrets from AWS Secrets Manager
  • Storing the resulting AMI ID in SSM Parameter Store

Extended IAM Configuration

Add a new file for the Packer-specific permissions:

oidc-packer.tf

# Permissions required for Packer to build AMIs
resource "aws_iam_role_policy" "packer" {
  name = "packer-build"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "PackerEC2ReadOnly"
        Effect = "Allow"
        Action = [
          "ec2:DescribeImages",
          "ec2:DescribeImageAttribute",
          "ec2:DescribeInstances",
          "ec2:DescribeInstanceStatus",
          "ec2:DescribeRegions",
          "ec2:DescribeSecurityGroups",
          "ec2:DescribeSnapshots",
          "ec2:DescribeSubnets",
          "ec2:DescribeTags",
          "ec2:DescribeVolumes"
        ]
        Resource = "*"
      },
      {
        Sid    = "PackerEC2Create"
        Effect = "Allow"
        Action = [
          "ec2:CreateImage",
          "ec2:CreateSnapshot",
          "ec2:CreateTags",
          "ec2:CreateVolume",
          "ec2:CreateKeyPair",
          "ec2:CreateSecurityGroup",
          "ec2:AuthorizeSecurityGroupIngress",
          "ec2:RegisterImage",
          "ec2:RunInstances",
          "ec2:GetPasswordData"
        ]
        Resource = "*"
      },
      {
        Sid    = "PackerEC2Modify"
        Effect = "Allow"
        Action = [
          "ec2:AttachVolume",
          "ec2:DeleteKeyPair",
          "ec2:DeleteSecurityGroup",
          "ec2:DeleteSnapshot",
          "ec2:DeleteVolume",
          "ec2:DeregisterImage",
          "ec2:DetachVolume",
          "ec2:ModifyImageAttribute",
          "ec2:ModifyInstanceAttribute",
          "ec2:ModifySnapshotAttribute",
          "ec2:StopInstances",
          "ec2:TerminateInstances"
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "ec2:ResourceTag/Creator" = "Packer"
          }
        }
      }
    ]
  })
}

# SSM Parameter to store the current golden image AMI ID
resource "aws_ssm_parameter" "golden_image_id" {
  name  = "/infrastructure/golden-image-id"
  type  = "String"
  value = "ami-placeholder"

  lifecycle {
    ignore_changes = [value]
  }
}

resource "aws_iam_role_policy" "ssm" {
  name = "ssm-parameter-access"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ssm:GetParameter",
          "ssm:GetParameters",
          "ssm:PutParameter"
        ]
        Resource = aws_ssm_parameter.golden_image_id.arn
      }
    ]
  })
}

# Secrets Manager for Ansible Vault password (optional)
resource "aws_secretsmanager_secret" "ansible_vault_pass" {
  name = "github-actions/ansible-vault-password"
}

resource "aws_iam_role_policy" "secrets_manager" {
  name = "secrets-manager-access"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue",
          "secretsmanager:DescribeSecret"
        ]
        Resource = aws_secretsmanager_secret.ansible_vault_pass.arn
      }
    ]
  })
}

Note the condition on destructive EC2 actions. Packer tags resources it creates with Creator = Packer. By requiring this tag for delete operations, we prevent the workflow from accidentally (or maliciously) affecting resources Packer didn't create.

Packer Workflow

.github/workflows/build-ami.yml

name: Build Golden Image

on:
  push:
    branches: [main]
    paths:
      - 'packer/**'
      - 'ansible/**'
      - '.github/workflows/build-ami.yml'
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  PACKER_VERSION: 1.10.0

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Validate Ansible syntax
        run: |
          pip install ansible ansible-lint
          ansible-playbook --syntax-check ansible/golden-image/main.yml
          ansible-lint ansible/

  build:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-oidc
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Packer
        uses: hashicorp/setup-packer@main
        with:
          version: ${{ env.PACKER_VERSION }}

      - name: Retrieve Ansible Vault password
        run: |
          aws secretsmanager get-secret-value \
            --secret-id github-actions/ansible-vault-password \
            --query SecretString \
            --output text > .vault-pass
          chmod 600 .vault-pass

      - name: Validate Packer template
        run: |
          packer init packer/
          packer validate packer/

      - name: Build AMI
        run: |
          packer build -timestamp-ui packer/
          AMI_ID=$(jq -r '.builds[-1].artifact_id' packer-manifest.json | cut -d: -f2)
          echo "AMI_ID=$AMI_ID" >> $GITHUB_ENV

      - name: Update SSM Parameter
        run: |
          aws ssm put-parameter \
            --name "/infrastructure/golden-image-id" \
            --value "$AMI_ID" \
            --overwrite
          echo "Updated golden-image-id to $AMI_ID"

      - name: Cleanup
        if: always()
        run: rm -f .vault-pass

A few implementation notes:

Path filters on the trigger. The workflow only runs when relevant files change, avoiding unnecessary builds on documentation updates or unrelated code changes.

Separate lint and build jobs. Fast feedback on syntax errors before committing to a potentially long Packer build.

Vault password handling. The password is retrieved from Secrets Manager at runtime, written to a temporary file, and cleaned up afterward. Never log or echo this value.

AMI ID propagation. The built AMI ID gets stored in SSM Parameter Store, where other infrastructure (Terraform, Auto Scaling Groups) can reference it dynamically.


Security Considerations

OIDC eliminates stored credentials, but it doesn't eliminate the need for security discipline.

Scope trust narrowly. repo:org/*:* trusts every repository in your organisation—probably not what you want. Each role should trust the minimum set of repositories and contexts it needs.

Least-privilege IAM policies. OIDC handles authentication. Authorisation (what the role can do) still requires careful IAM policy design. The Packer example above demonstrates condition-based restrictions on destructive actions.

Protect workflow files. Anyone who can modify .github/workflows/ can change what code runs with your AWS permissions. Use branch protection rules and require reviews for workflow changes.

Pin action versions. Rather than uses: aws-actions/configure-aws-credentials@v4, consider pinning to a specific commit SHA to prevent supply chain attacks through compromised actions:

uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2

Monitor CloudTrail. OIDC provides better audit trails than shared access keys. Make use of them. Set up alerts for unexpected role assumptions or unusual API calls.

What OIDC Doesn't Protect Against

  • Overly permissive IAM policies
  • Compromised GitHub accounts with write access to your repository
  • Malicious code in dependencies your workflow installs
  • Misconfigured workflows that leak sensitive data to logs

Cleanup

To remove the resources created in this tutorial:

terraform destroy -var="github_org=your-username" -var="github_repo=your-repo"

Note: If other repositories or roles depend on the OIDC provider, you may want to keep it. AWS allows only one OIDC provider per issuer URL per account, so removing it would affect all roles that trust GitHub.


Summary

We've covered:

  • Why OIDC is worth the setup effort — ephemeral credentials, precise scoping, no secrets to manage
  • How the authentication flow works — JWT issuance, trust validation, credential exchange
  • Minimal implementation — OIDC provider, IAM role, and a test workflow
  • Real-world application — building AMIs with Packer, including Secrets Manager and SSM integration
  • Security considerations — scoping trust, least-privilege policies, and what OIDC doesn't solve

The pattern extends beyond AWS. Part 2 covers the same approach for Azure, where the concepts are identical but the implementation differs.

Once you've implemented OIDC for one workflow, extending it to others is straightforward: create additional IAM roles with appropriate trust policies and permissions, reference them in your workflows. The OIDC provider itself is shared across all of them.


Next: Part 2 — Eliminating Static Azure Credentials in GitHub Actions with OIDC