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.

Intermediate

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:latest

The configure-aws-credentials action automatically:

  1. Generates an OIDC token from GitHub
  2. Exchanges it for temporary AWS credentials
  3. Sets environment variables so subsequent steps can use aws CLI commands
  4. 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 main branch; 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 code

Without 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?