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
- Why OIDC Over Access Keys
- How OIDC Authentication Works
- Prerequisites
- Implementation: The Minimal Setup
- Real-World Example: Building AMIs with Packer
- Security Considerations
- 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.
- Establish trust. You create an IAM OIDC Identity Provider in AWS that trusts GitHub's OIDC endpoint. This is a one-time configuration.
- 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.
- 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.
- Workflow presents the token to AWS. Using the aws-actions/configure-aws-credentials action, the workflow calls sts:AssumeRoleWithWebIdentity, presenting the JWT.
- 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.

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
subcondition - The
audclaim doesn't match (should bests.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