Solving the AWS OIDC Chicken-and-Egg Problem with GitHub Actions

2025-08-27

Solving the AWS OIDC Chicken-and-Egg Problem with GitHub Actions

AWS + GitHub OIDC: Escape from Long-Lived Keys

Everyone wants to get rid of IAM access keys in CI/CD. They’re risky, hard to rotate, and inevitably leak.

AWS solved this with OIDC federation: your GitHub Actions runner requests a short-lived token directly from AWS STS using a signed OIDC identity, no secrets required.

It sounds perfect. But there’s one catch: 👉 To configure OIDC in the first place, you need AWS credentials.

Welcome to the bootstrap paradox.

Environment Setup

mkdir oidc-bootstrap
cd oidc-bootstrap

We’ll explore this bootstrap step from first principles and then run a full demo of GitHub Actions authenticating into AWS without static keys.

The Chicken-and-Egg Problem

First principles:

  • AWS will only trust GitHub’s OIDC provider once you explicitly register it.

  • Terraform needs state storage in S3, but you can’t even terraform init unless the bucket exists.

  • Docker pushes to ECR fail if the repo doesn’t exist.

So how do you create those resources… if the very credentials you need to do so don't exist yet?

Answer: you bootstrap once with minimal, temporary credentials.

Bootstrapping the OIDC Trust

Step 1 — Create an OIDC provider in IAM:

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
  --client-id-list sts.amazonaws.com

Step 2 — Create an IAM role trusted by GitHub:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<account_id>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:moabukar/oidc-bootstrap:*"
        }
      }
    }
  ]
}
aws iam create-role \
  --role-name github-oidc-role \
  --assume-role-policy-document file://trust-policy.json

Attach permissions:

aws iam attach-role-policy \
  --role-name github-oidc-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

At this point, AWS trusts GitHub. Now you can safely delete the bootstrap credentials.

GitHub Actions in Steady State

No long-lived IAM keys needed — just OIDC:

name: Deploy
on: [push]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<account_id>:role/github-oidc-role
          aws-region: eu-west-2

      - run: aws s3 ls

GitHub Actions fetches an OIDC token, exchanges it for AWS STS creds, and runs with no static secrets.

Example Scenarios

A. Terraform Remote State (S3)

Bootstrap credentials create S3 bucket + DynamoDB table.

Switch Terraform backend to S3.

All future terraform apply runs in GitHub Actions via OIDC.

B. ECR Repository

Bootstrap credentials create repo with aws ecr create-repository.

Pipelines authenticate via OIDC and push images.

C. Lambda Deployment

Bootstrap user creates role + trust policy.

GitHub OIDC assumes role and deploys Lambda updates seamlessly.

Test

To see the token exchange in action:

aws sts get-caller-identity

From GitHub Actions, the output shows the IAM role ARN, not a static user. That’s how you know OIDC federation is working.

Lessons Learned

  • Bootstrap once. OIDC can't configure itself.

  • Scope narrowly. Create the minimum S3/ECR/roles you need, then cut off bootstrap access.

  • Delete long-lived keys. After setup, your AWS account should run 100% keyless.

Pain Point: The Paradox in Practice

This bootstrap paradox frustrates everyone:

  • New engineers expect OIDC to “just work” and wonder why Terraform can’t even create its own state bucket.

  • Security teams get nervous when they see IAM users reappearing.

The fix is boring but necessary: one minimal bootstrap, then never again.