CI/CD: GitHub Actions and OIDC federation
Deploying code to AWS (or any cloud) requires credentials. Long-lived access keys are a security liability — steal them and someone can access your cloud account. OIDC federation: GitHub proves which repository a workflow is running in, and AWS temporarily grants permissions just for that workflow, without long-lived secrets.
4 min read
The old way: access keys in GitHub Secrets
Deploy code to AWS → GitHub Actions needs AWS credentials → store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub Secrets → workflow uses those credentials.
Problems:
- Leaks everywhere: if a secret is accidentally printed to logs or included in a commit, it's compromised forever.
- Rotation is painful: updating a key means updating every secret everywhere it's used.
- Broad permissions: the key typically has permissions to deploy anything in your AWS account, so a leak is catastrophic.
- Audit trail is weak: logs show "some secret was used" but not which repository or workflow.
The new way: OIDC federation
OpenID Connect (OIDC) is a standard for proving identity. GitHub can issue OIDC tokens that say "this workflow is running in repository alice/my-app on branch main." AWS trusts GitHub and exchanges those tokens for temporary credentials — no permanent secrets needed.
Setting up OIDC trust in AWS
First, create an OIDC provider in your AWS account that trusts GitHub:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# GitHub publishes a signing certificate; include its thumbprint
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aca1"]
}Then create an IAM role that GitHub workflows can assume:
resource "aws_iam_role" "github_deploy" {
name = "github-deploy-role"
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:alice/my-app:ref:refs/heads/main"
}
}
}]
})
}
# Attach deployment permissions to the role
resource "aws_iam_role_policy" "deploy_policy" {
role = aws_iam_role.github_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ecr:PutImage",
"ecr:GetAuthorizationToken",
"lambda:UpdateFunctionCode"
]
Resource = [
"arn:aws:ecr:us-east-1:123456789:repository/my-app",
"arn:aws:lambda:us-east-1:123456789:function:my-function"
]
}]
})
}The Condition block is critical: the workflow can only assume the role if it's running in the alice/my-app repository on the main branch. No other repository, no other branch, no manually-created OIDC tokens can use this role.
Using OIDC in a GitHub Actions workflow
name: Deploy
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Assume AWS role
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy-role
aws-region: us-east-1
- name: Push to ECR and deploy
run: |
aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com
docker build -t my-app:latest .
docker tag my-app:latest $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
docker push $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/my-app:latestThe configure-aws-credentials action automatically:
- Generates an OIDC token from GitHub
- Exchanges it for temporary AWS credentials
- Sets environment variables so subsequent steps can use
awsCLI commands - Cleans up credentials when the job ends
No secrets stored; no keys to rotate.
Narrowing permissions: defense in depth
Even with OIDC, apply least-privilege to the role:
- Different roles for different workflows: don't give a canary-test workflow the same permissions as a production-deploy workflow.
- Resource-scoped permissions: the role can only push to specific ECR repositories, not all repositories in the account.
- Branch conditions: the role can only be assumed from the
mainbranch; pull request workflows use a different, more-restricted role.
# Main-branch deploy role (can update production)
resource "aws_iam_role" "github_prod_deploy" {
assume_role_policy = jsonencode({
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:alice/my-app:ref:refs/heads/main"
}
}
})
}
# PR workflow role (can only run tests, not deploy)
resource "aws_iam_role" "github_pr_checks" {
assume_role_policy = jsonencode({
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:alice/my-app:pull_request"
}
}
})
}Common gotchas
Thumbprint changes: GitHub's OIDC signing certificate eventually rotates. AWS publishes a new thumbprint, and you must update your Terraform configuration. Most CI/CD services automate this, but Terraform's thumbprint list is static — set a calendar reminder to update it annually, or use a data source that fetches it dynamically.
Overly broad sub condition: sub: "repo:alice/my-app:*" allows any branch to assume the role. Tighten it to specific branches or environments. Even better, use ref:refs/heads/main for production deploys.
Missing permissions block: workflows need id-token: write permission to generate and sign OIDC tokens:
permissions:
id-token: write # required for OIDC
contents: read # required to check out codeWithout this, the OIDC token generation fails silently or with a cryptic error.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why is OIDC federation safer than storing AWS credentials in GitHub Secrets?
2. What does the 'sub' condition in an OIDC trust policy control, and why is it important?
3. Which GitHub Actions workflow permission is required for OIDC to work?
4. Why should production and development deployments use different IAM roles?