·4 min read·#aws#github-actions#security#cicd#terraform

Keyless CI/CD with GitHub Actions OIDC

Keyless CI/CD with GitHub Actions OIDC architecture diagram

Storing AWS access keys in GitHub secrets is a security liability. Keys don't expire, can be leaked, and are difficult to rotate. There's a better way: OpenID Connect (OIDC) authentication.

With OIDC, GitHub Actions requests temporary credentials from AWS for each workflow run. Nothing long-lived, nothing stored, nothing to rotate.

How OIDC Authentication Works

The flow involves three parties: GitHub, AWS, and your workflow.

aws-account-structure-part-5-github-oidc/oidc-flow diagram
Click to expand
714 × 722px

Step 1: Your workflow requests a JWT token from GitHub's OIDC provider. This token contains claims about the workflow - the repository, branch, actor, and more.

Step 2: The workflow presents this token to AWS Security Token Service (STS).

Step 3: AWS validates the token against the OIDC provider configuration. If the claims match the IAM role's trust policy, AWS returns temporary credentials.

Step 4: The workflow uses these credentials (valid for 1 hour by default) to interact with AWS.

No long-lived secrets involved.

Setting Up the OIDC Provider

First, create an OIDC identity provider in AWS. This establishes the trust relationship with GitHub:

hcl
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  # GitHub's OIDC provider thumbprint
  # This is stable and provided by GitHub
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]

  tags = {
    Name      = "GitHub Actions OIDC"
    ManagedBy = "OpenTofu"
  }
}

The thumbprint is required by the API, but AWS no longer uses it for validation when the OIDC provider URL uses a certificate from a trusted CA (which GitHub does). It's effectively a no-op, but you still need to provide it.

Creating the IAM Role

The IAM role defines what GitHub Actions can do. The trust policy controls which repositories can assume it:

hcl
resource "aws_iam_role" "github_actions" {
  name = "${var.organization_name}-github-actions"

  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}/*:*"
          }
        }
      }
    ]
  })
}

Understanding the Trust Policy

The Condition block is critical for security:

token.actions.githubusercontent.com:aud: Must equal "sts.amazonaws.com". This ensures the token was intended for AWS.

token.actions.githubusercontent.com:sub: The subject claim. Format is repo:OWNER/REPO:REF. We use a wildcard (repo:myorg/*:*) to allow any repository in the organization.

This broad trust is intentional for early bootstrap flexibility. As repositories and pipelines stabilise, tighten it to specific repositories and protected refs/environments.

Field note from implementation: we started with an org-wide wildcard while the repository split was still moving, then narrowed trust once deployment paths were stable.

Stagesub scopeTrade-off
Early bootstraprepo:org/*:*Fast setup, wider blast radius
Steady staterepo:org/repo:ref:... or environment-boundBetter isolation, more policy maintenance

You can be more restrictive:

hcl
# Specific repository only
"repo:myorg/aws-bootstrap:ref:refs/heads/main"

# Any branch in specific repo
"repo:myorg/aws-bootstrap:*"

# Pull requests in specific repo
"repo:myorg/aws-bootstrap:pull_request"

Attaching Permissions

The role needs permissions to do its job. We use a combination of managed policies and custom policies depending on the use case.

For the bootstrap role (which creates infrastructure), we initially used AdministratorAccess:

hcl
resource "aws_iam_role_policy_attachment" "github_admin" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

In our implementation this was explicitly temporary and tracked as a removal item. Exit criteria were simple: scoped deployment roles existed in each target account and pipelines were green without admin rights.

This works for bootstrapping, but it violates least privilege. A better approach is scoped deployment roles (covered in Part 8).

Configuring the GitHub Workflow

The workflow needs permission to request OIDC tokens:

yaml
name: Deploy Infrastructure

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

permissions:
  id-token: write   # Required for OIDC
  contents: read    # Required for checkout

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/myorg-github-actions
          aws-region: eu-west-2

      - name: Verify AWS access
        run: aws sts get-caller-identity

The permissions.id-token: write is essential. Without it, GitHub won't issue OIDC tokens.

Environment-Specific Roles

For different environments, use different roles with different permissions:

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_STAGING_ROLE_ARN }}
          aws-region: eu-west-2

  deploy-production:
    environment: production
    needs: deploy-staging
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_PRODUCTION_ROLE_ARN }}
          aws-region: eu-west-2

The role ARN is still stored as a secret, but it's not sensitive - knowing the ARN doesn't grant access. Only workflows matching the trust policy can assume the role.

Cross-Account Access

With OIDC credentials in hand, the next challenge is reaching other accounts. The OIDC role in the management account can chain to scoped deployment roles in member accounts - the workflow assumes the OIDC role first, then assumes a second role in the target account.

This role chaining pattern, including trust policies, external ID protection, and the deployment role design, is covered in detail in Part 8.

Limiting Token Lifetime

Temporary credentials default to 1 hour. You can reduce this:

yaml
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
    aws-region: eu-west-2
    role-duration-seconds: 900  # 15 minutes

Shorter durations reduce the window if credentials are somehow exposed during the run.

Debugging OIDC Issues

When OIDC authentication fails, the error messages can be cryptic. These are the two failures I see most often:

ErrorUsually meansWhat to check
"Not authorized to perform sts:AssumeRoleWithWebIdentity"Token claims do not satisfy the role trust policyTrust policy conditions vs actual claims, permissions.id-token: write, OIDC provider thumbprint
"The specified resource does not exist"Referenced IAM/OIDC resource is wrong or missingRole ARN value, existence of IAM OIDC provider

To see the actual claims in your token:

yaml
- name: Debug OIDC token
  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 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

This decodes the JWT and shows the claims AWS will evaluate.

Why This Matters

With OIDC in place, credentials are fresh every run, there's nothing stored that can leak, and CloudTrail shows exactly which workflow assumed which role. Trust policies can limit access down to specific repositories, branches, and environments.

Every new repository should use OIDC. Long-lived AWS keys should be the exception, not the starting point.


← Back to all posts