Skip to content
Back to blog GitHub Actions OIDC – Ditch the AWS Access Keys Forever

GitHub Actions OIDC – Ditch the AWS Access Keys Forever

CICDSecurity

Stop storing AWS access keys in GitHub Secrets. There’s a better way.

GitHub Actions supports OIDC (OpenID Connect) federation, which means your workflows can assume IAM roles directly – no long-lived credentials, no rotation headaches, no secrets to leak.

Here’s how it works and how to set it up properly.

Code Repository: All code from this post is available at github.com/moabukar/blog-code/github-actions-oidc

The Problem with Access Keys

Traditional CI/CD authentication looks like this:

  1. Create IAM user
  2. Generate access key
  3. Store in GitHub Secrets
  4. Hope nobody leaks them
  5. Forget to rotate them
  6. Get breached

Access keys are:

  • Long-lived – valid until you delete them
  • Static – same credentials for every workflow run
  • Broadly scoped – often over-permissioned “just to make CI work”
  • Hard to audit – which workflow used these keys when?

OIDC: The Better Way

With OIDC federation:

  1. GitHub Actions requests a short-lived token from GitHub’s OIDC provider
  2. Your workflow presents this token to AWS
  3. AWS validates the token against GitHub’s public keys
  4. AWS issues temporary credentials (15 min - 1 hour)
  5. Workflow runs with those credentials
  6. Credentials automatically expire

No secrets stored. No keys to rotate. Every workflow run gets unique, short-lived credentials.

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  GitHub Actions │────►│ GitHub OIDC     │────►│      AWS        │
│    Workflow     │     │ Provider        │     │   IAM Role      │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                       │                       │
        │ 1. Request token      │                       │
        │──────────────────────►│                       │
        │                       │                       │
        │ 2. JWT with claims    │                       │
        │◄──────────────────────│                       │
        │                       │                       │
        │ 3. AssumeRoleWithWebIdentity                  │
        │──────────────────────────────────────────────►│
        │                       │                       │
        │ 4. Temporary credentials                      │
        │◄──────────────────────────────────────────────│

Setting It Up

Step 1: Create the OIDC Provider in AWS

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

Or with Terraform:

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

Step 2: Create the IAM Role

This is where the magic happens. The trust policy controls which GitHub repos/branches can assume the role:

data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.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:myorg/myrepo:*"]
    }
  }
}

resource "aws_iam_role" "github_actions" {
  name               = "github-actions-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}

resource "aws_iam_role_policy_attachment" "github_actions" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"  # Scope this down!
}

Step 3: Configure Your Workflow

name: Deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC
  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: eu-west-1
      
      - name: Deploy
        run: |
          aws s3 sync ./dist s3://my-bucket

That’s it. No AWS_ACCESS_KEY_ID. No AWS_SECRET_ACCESS_KEY. Just the role ARN.

Token Claims: The Security Controls

The GitHub OIDC token contains claims that you can use in IAM trust policies. This is where you lock things down:

ClaimExampleUse Case
subrepo:myorg/myrepo:ref:refs/heads/mainRestrict to specific repo/branch
repositorymyorg/myrepoMatch repository name
repository_ownermyorgMatch organization
refrefs/heads/mainMatch branch/tag
environmentproductionMatch GitHub environment
job_workflow_refmyorg/myrepo/.github/workflows/deploy.yml@refs/heads/mainMatch specific workflow file
actoroctocatMatch user who triggered
event_namepushMatch trigger event

Restricting by Repository

Only allow a specific repo:

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
    }
  }
}

Restricting by Branch

Only allow main branch:

{
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
    }
  }
}

Restricting by Environment

Only allow the production GitHub environment:

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
    }
  }
}

This is powerful – you can require manual approval in GitHub before the role can be assumed.

Restricting by Organization

Allow any repo in your org:

{
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:myorg/*:*"
    }
  }
}

Common Patterns

Different Roles per Environment

# Production role - only main branch
resource "aws_iam_role" "github_actions_prod" {
  name = "github-actions-prod"
  
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRoleWithWebIdentity"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          "token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:environment:production"
        }
      }
    }]
  })
}

# Staging role - any branch
resource "aws_iam_role" "github_actions_staging" {
  name = "github-actions-staging"
  
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRoleWithWebIdentity"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:*"
        }
      }
    }]
  })
}

Workflow with Environment Gates

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-staging
          aws-region: eu-west-1

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-prod
          aws-region: eu-west-1

Debugging OIDC Issues

”Not authorized to perform sts:AssumeRoleWithWebIdentity”

Check your trust policy conditions. Print the token claims to see what you’re actually getting:

- name: Debug OIDC token
  run: |
    TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
    echo "Token claims:"
    echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .

“Audience validation failed”

Make sure your IAM trust policy checks for sts.amazonaws.com:

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    }
  }
}

“Subject claim mismatch”

The sub claim format varies based on the trigger:

  • Push: repo:org/repo:ref:refs/heads/branch
  • PR: repo:org/repo:pull_request
  • Environment: repo:org/repo:environment:name

Use StringLike with wildcards if needed.

Beyond AWS

OIDC works with other clouds too:

Azure

- uses: azure/login@v1
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

GCP

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github
    service_account: github-actions@my-project.iam.gserviceaccount.com

HashiCorp Vault

- uses: hashicorp/vault-action@v2
  with:
    url: https://vault.example.com
    method: jwt
    role: github-actions
    jwtGithubAudience: https://vault.example.com

Summary

ApproachCredentialsLifetimeRotationBlast Radius
Access KeysStaticIndefiniteManualHigh
OIDCDynamic15-60 minAutomaticLow

OIDC is:

  • More secure – no long-lived credentials
  • Easier to audit – every workflow run has unique credentials
  • Granular – control access by repo, branch, environment
  • Zero maintenance – no keys to rotate

Stop storing AWS keys in GitHub. OIDC has been stable since 2021. There’s no excuse.


Further reading: GitHub OIDC docs and AWS federation guide.

Found this helpful?

Comments