Confession first: until last month, this website deployed to S3 and CloudFront using an IAM user with static access keys stored as GitHub secrets. I have told clients to stop doing exactly this. I have flagged it in security reviews. And my own pipeline sat there with a key pair created in 2022 that had never been rotated.

The reason is boring and universal: static keys work, and nothing forces you to touch them. That's precisely what makes them dangerous.

Why static keys in CI are a liability

A long-lived access key in a CI system has three problems that compound each other:

  1. It never expires. If it leaks, it works until someone notices. The average time-to-exploitation for AWS keys leaked to public GitHub repos is measured in minutes. Bots scan for them constantly.
  2. It leaks through more channels than you think. Fork PRs, compromised third-party actions, a misconfigured env dump in a debug step, a laptop that once ran act locally. Every one of these is a copy of a credential that doesn't expire.
  3. Nobody rotates it. Rotation requires coordination between the IAM console and the CI secret store, so in practice it happens never. I checked a client's estate last year: 14 CI-related IAM users, oldest key age 4.5 years.

The fix is to stop having a credential at all. With OIDC federation, GitHub Actions proves its identity to AWS cryptographically per job run, and AWS hands back temporary credentials scoped to a role. Nothing to store, nothing to leak, nothing to rotate.

How the federation actually works

The flow is worth understanding because the trust policy conditions only make sense once you see it:

  1. GitHub runs an OIDC identity provider at token.actions.githubusercontent.com. Every workflow job can request a signed JWT from it.
  2. That JWT contains claims about the run: which repo (sub), which branch or tag, which workflow, and an audience (aud).
  3. Your workflow calls sts:AssumeRoleWithWebIdentity, presenting the JWT.
  4. AWS validates the token signature against GitHub's public keys, checks the claims against your role's trust policy conditions, and if they match, issues short-lived credentials (15 minutes to a few hours).

The security boundary lives entirely in the trust policy. Get the conditions wrong and you've built a role any GitHub repo on the planet can assume.

The migration, step by step

Here's what I did for this site's S3 + CloudFront pipeline. Total time: about 40 minutes, most of it spent reading my own old Terraform.

1. Create the OIDC provider in AWS

Once per account:

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

(AWS now validates GitHub's certs directly, so the thumbprint is largely vestigial, but the field is still required.)

2. Create the role with a tight trust policy

This is the part to get right:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:AndrewTtofi/personal-website:ref:refs/heads/main"
        }
      }
    }
  ]
}

Two conditions, both mandatory in my book:

  • aud must equal sts.amazonaws.com. Without it, a token minted for some other audience could be replayed here.
  • sub pins the repo and ref. The claim format is repo:ORG/REPO:ref:refs/heads/BRANCH for branch pushes. Pinning to main means a workflow on a feature branch (or a fork) can't assume the deploy role.

The role's permission policy is the usual: s3:PutObject/s3:DeleteObject/s3:ListBucket on the site bucket, plus cloudfront:CreateInvalidation on the one distribution. Nothing else.

3. Update the 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
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy-site
          aws-region: eu-central-1
      - run: npm ci && npm run build
      - run: aws s3 sync dist/ s3://my-site-bucket --delete
      - run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ vars.CF_DIST_ID }} \
            --paths "/*"

The permissions: id-token: write block is the step everyone forgets. Without it the runner can't request the JWT and you get a cryptic "Credentials could not be loaded" error that mentions nothing about permissions.

4. Delete the IAM user

Not "disable the keys". Delete the user. If it exists, it will get reused.

Gotchas I hit (or have watched clients hit)

Wildcard subs are a footgun. repo:my-org/* in the sub condition means any repo in the org, including the sandbox repo someone made public, with a workflow anyone can PR against. If you must share a role across repos, enumerate them. If you must use a wildcard, at least keep the ref pinned: repo:my-org/*:ref:refs/heads/main is bad, repo:my-org/*:* is a breach waiting for a fork.

Environments change the sub claim. If your job uses a GitHub environment (environment: production), the claim becomes repo:ORG/REPO:environment:production. Your ref-based condition silently stops matching and the assume fails. Pick one shape and be consistent.

Session duration limits. The default max session for AssumeRoleWithWebIdentity is one hour. Long deploy jobs (big Terraform applies, container builds pushing to ECR) can outlive their credentials mid-run. Bump max_session_duration on the role and pass role-duration-seconds in the action. I set 2 hours for infra pipelines and leave app deploys at the default.

Tag builds need their own condition. Deploying on tags? The sub is repo:ORG/REPO:ref:refs/tags/v1.2.3, so you'll want refs/tags/* in a StringLike, and accept that this widens who can trigger a deploy to anyone who can push a tag.

The honest cost-benefit

The migration is under an hour per pipeline once you've done it twice. In exchange you remove an entire class of incident (leaked CI credentials) and you get better CloudTrail attribution for free, because the assumed-role session name tells you exactly which workflow run made each API call.

There's no residual reason to keep static keys in GitHub Actions for AWS. I was the cobbler with the barefoot children on this one. If your pipeline still has AWS_SECRET_ACCESS_KEY in its secrets list, block out an hour this week.