Every stored credential is a liability. Every long-lived access key is an incident waiting to happen. Yet most organizations — including those operating in regulated government environments — still have AWS access keys sitting in CI/CD secrets stores, rotated quarterly if they’re lucky, and shared across pipelines if they’re not.
OIDC (OpenID Connect) federation eliminates this entire class of risk. Instead of storing AWS credentials, your CI/CD provider authenticates directly with AWS IAM using short-lived tokens that expire in minutes. No secrets to store. No keys to rotate. No credentials to leak.
We’ve implemented this pattern across every pipeline we operate. Here’s the architecture and the code.
The Problem with Stored Credentials
Before OIDC, deploying to AWS from a CI/CD pipeline required an IAM user with programmatic access keys. Those keys — an Access Key ID and Secret Access Key — were stored as pipeline secrets in GitHub Actions, GitLab CI, or whatever platform ran the build.
The security issues are well-documented:
- Static credentials — the keys don’t change unless manually rotated. A key compromised six months ago still works today if no one rotated it.
- Broad permissions — pipeline IAM users often accumulate permissions over time. What started as “deploy this Lambda” becomes “admin access to three accounts” because someone needed to fix a production issue once.
- Shared across pipelines — the same credentials often serve multiple repositories, multiple environments, and multiple teams. Compromise one pipeline, compromise everything.
- Invisible blast radius — when a key leaks (and they do — in logs, in screenshots, in misconfigured S3 buckets), the blast radius includes every resource that IAM user can touch.
NIST 800-171 Control 3.5.2 requires organizations to “authenticate (or verify) the identities of users, processes, or devices as a prerequisite for allowing access.” Long-lived shared credentials fail this control in spirit even if they technically pass on paper. OIDC federation aligns with the control’s intent by authenticating each pipeline run individually.
How OIDC Federation Works
The OIDC flow for CI/CD to AWS involves four components:
- Identity Provider (IdP) — your CI/CD platform (GitHub Actions, GitLab CI, etc.) acts as the OIDC provider, issuing signed JWT tokens for each pipeline run.
- AWS IAM OIDC Provider — an IAM entity that trusts the CI/CD platform’s OIDC endpoint and validates JWT signatures.
- IAM Role — a role with a trust policy scoped to the specific OIDC provider, repository, branch, and environment.
- STS AssumeRoleWithWebIdentity — the API call that exchanges the JWT token for temporary AWS credentials (valid for 1 hour by default).
The flow:
Pipeline starts → CI/CD platform issues JWT →
Pipeline requests AWS credentials via STS →
STS validates JWT against OIDC provider →
STS returns temporary credentials (1hr TTL) →
Pipeline deploys using temporary credentials →
Credentials expire automatically No secrets stored anywhere. Each pipeline run gets unique, short-lived credentials scoped to a specific role. If the credentials leak, they expire before anyone can use them.
Implementation: GitHub Actions to AWS
Here’s the complete implementation for GitHub Actions, the most common pattern we deploy.
Step 1: Create the OIDC Provider in AWS
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
} The thumbprint authenticates GitHub’s OIDC endpoint. AWS uses this to verify that the JWT tokens your pipeline presents actually come from GitHub, not an attacker spoofing the endpoint.
Step 2: Create the IAM Role with Scoped Trust Policy
data "aws_iam_policy_document" "github_actions_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:your-org/your-repo:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "github_actions_deploy" {
name = "github-actions-deploy"
assume_role_policy = data.aws_iam_policy_document.github_actions_trust.json
max_session_duration = 3600
}
resource "aws_iam_role_policy_attachment" "deploy_permissions" {
role = aws_iam_role.github_actions_deploy.name
policy_arn = aws_iam_policy.deploy_policy.arn
} The critical element is the sub condition. This scopes the trust to a specific repository and branch. Without this condition, any GitHub Actions workflow in any repository could assume your role — a catastrophic misconfiguration we’ve seen in production environments.
Step 3: Configure the GitHub Actions Workflow
name: Deploy Infrastructure
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- 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-deploy
aws-region: us-west-2
role-session-name: github-actions-${{ github.run_id }}
- name: Deploy with Terraform
run: |
terraform init
terraform plan -out=tfplan
terraform apply tfplan The permissions.id-token: write line is required — it allows the workflow to request an OIDC token from GitHub. The role-session-name includes the run ID, creating a unique session identifier that appears in CloudTrail logs for audit traceability.
Multi-Account OIDC Architecture
Production government environments don’t run in a single AWS account. They use AWS Organizations with separate accounts for development, staging, production, security, and shared services. OIDC needs to work across this structure.
The pattern:
locals {
environments = {
dev = {
account_id = "111111111111"
branch = "develop"
}
staging = {
account_id = "222222222222"
branch = "refs/heads/staging"
}
prod = {
account_id = "333333333333"
branch = "refs/heads/main"
}
}
}
resource "aws_iam_openid_connect_provider" "github_actions" {
for_each = local.environments
provider = aws.accounts[each.key]
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "deploy" {
for_each = local.environments
provider = aws.accounts[each.key]
name = "github-actions-deploy-${each.key}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github_actions[each.key].arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:your-org/your-repo:ref:${each.value.branch}"
}
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
}
}]
})
} Each account gets its own OIDC provider and deploy role. The trust policy on each role only allows assumption from the corresponding branch. The main branch can deploy to production. The develop branch can only touch the dev account. Branch protection becomes deployment protection.
This architecture integrates directly with our Terraform multi-account AWS patterns and extends the zero-trust credential elimination approach we’ve detailed previously.
GitLab CI OIDC Configuration
For teams running GitLab CI, the OIDC implementation differs slightly:
resource "aws_iam_openid_connect_provider" "gitlab" {
url = "https://gitlab.com"
client_id_list = ["https://gitlab.com"]
thumbprint_list = ["b3dd7606d2b5a8b4a13771dbecc9ee1cecafa38a"]
}
data "aws_iam_policy_document" "gitlab_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.gitlab.arn]
}
condition {
test = "StringEquals"
variable = "gitlab.com:sub"
values = ["project_path:your-group/your-project:ref_type:branch:ref:main"]
}
}
} # .gitlab-ci.yml
deploy:
stage: deploy
image: hashicorp/terraform:latest
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
variables:
ROLE_ARN: "arn:aws:iam::123456789012:role/gitlab-deploy"
before_script:
- >
export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
$(aws sts assume-role-with-web-identity
--role-arn ${ROLE_ARN}
--role-session-name "gitlab-ci-${CI_PIPELINE_ID}"
--web-identity-token ${GITLAB_OIDC_TOKEN}
--duration-seconds 3600
--query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]"
--output text))
script:
- terraform init
- terraform apply -auto-approve The GitLab sub claim structure differs from GitHub’s, so the trust policy condition must match GitLab’s token format. This is a common misconfiguration — copying GitHub trust policies for GitLab deployments will silently fail.
Audit and Compliance Benefits
OIDC federation produces a compliance-friendly audit trail by default:
CloudTrail records every credential assumption — each AssumeRoleWithWebIdentity call logs the source repository, branch, pipeline run ID, and the temporary credential session name. An auditor can trace any AWS API call back to the specific pipeline run that initiated it.
No credential rotation required — because there are no stored credentials, the credential rotation controls in NIST 800-171 (3.5.8, 3.5.9) are satisfied by design. There’s nothing to rotate.
Principle of least privilege enforced structurally — the trust policy’s sub condition limits which repositories and branches can assume which roles. This isn’t a policy someone might forget to enforce — it’s built into the IAM configuration.
Session duration limits — temporary credentials expire after 1 hour (configurable down to 15 minutes). Even if credentials leak mid-pipeline, the window of exposure is measured in minutes, not months.
For teams working toward FedRAMP readiness or CMMC compliance, OIDC federation addresses multiple control families simultaneously — access control, identification and authentication, audit and accountability, and system and communications protection.
Common Misconfigurations to Avoid
Missing sub condition — the single most dangerous OIDC misconfiguration. Without scoping the trust policy to a specific repository, any repository in the OIDC provider’s domain can assume the role.
Overly broad branch patterns — using repo:org/* instead of repo:org/specific-repo:ref:refs/heads/main grants access to every repository in the organization. Be explicit.
Forgotten id-token: write permission — GitHub Actions won’t request an OIDC token unless the workflow explicitly declares this permission. The error message isn’t always obvious.
Thumbprint staleness — OIDC provider TLS certificates rotate periodically. AWS caches the thumbprint, but if it changes, authentication fails. Monitor for this in your pipeline alerting.
Session duration too long — the default 1-hour session is appropriate for most deployments. Extending it to 12 hours “for convenience” defeats much of the security benefit. Keep sessions as short as your deployment requires.
Migration Path from Stored Credentials
Migrating existing pipelines to OIDC is straightforward:
- Deploy the OIDC provider and IAM roles via Terraform (infrastructure change, no pipeline impact)
- Update one non-critical pipeline to use OIDC authentication
- Verify CloudTrail shows the
AssumeRoleWithWebIdentitycall with correct session metadata - Roll out to remaining pipelines
- Delete the IAM user and its access keys
- Remove stored secrets from the CI/CD platform
The migration is backward-compatible — OIDC roles and IAM user credentials can coexist during the transition. There’s no reason to cut over everything simultaneously.
Frequently Asked Questions
Does OIDC work with AWS GovCloud?
Yes. AWS GovCloud supports IAM OIDC providers and AssumeRoleWithWebIdentity. The configuration is identical to commercial AWS regions. The OIDC provider URL and thumbprint are the same because the identity provider (GitHub, GitLab) is external to AWS — only the IAM role and trust policy exist within GovCloud.
What happens if the OIDC token expires mid-deployment?
GitHub Actions OIDC tokens are valid for the duration of the workflow job, and the STS temporary credentials are valid for the configured session duration (default 1 hour). If your deployment takes longer than the session duration, the credentials expire and subsequent AWS API calls fail. For long-running deployments, increase the max_session_duration on the IAM role (up to 12 hours) or break the deployment into shorter jobs.
Can I use OIDC with Terraform Cloud or Spacelift?
Yes. Both Terraform Cloud and Spacelift support OIDC federation with AWS. The configuration pattern is the same — create an OIDC provider in AWS that trusts the platform’s token endpoint, and scope the IAM role trust policy to specific workspaces or projects. Each platform has its own sub claim format, so consult their documentation for the correct condition values.
How do I audit which pipeline deployed a specific change?
CloudTrail logs every AssumeRoleWithWebIdentity call with the full JWT claims, including repository name, branch, and pipeline run ID. The role-session-name parameter in the pipeline configuration creates a human-readable session identifier. Correlating CloudTrail entries with pipeline logs gives you a complete chain of custody from code commit to production change.
Is OIDC compatible with CMMC Level 2 requirements?
OIDC federation directly supports multiple CMMC Level 2 practices: AC.L2-3.1.1 (authorized access control), IA.L2-3.5.2 (device authentication), AU.L2-3.3.1 (system-level auditing), and SC.L2-3.13.8 (session authenticity). By eliminating stored credentials and providing per-session authentication with full audit trails, OIDC strengthens the compliance posture across several control families simultaneously.
Discuss your project with Rutagon
Contact Us →