← All writing

Deleting our AWS access keys: GitHub Actions to AWS with OIDC

Static IAM keys in CI are the easiest credential in your organisation to leak and the hardest to notice leaking. OIDC federation removes them entirely. Here is the setup I use, and the two things that wasted my afternoon.

The problem with keys in CI

A repository secret holding AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY has three properties that should worry you: it never expires, it is valid from anywhere on the internet, and any workflow in the repository can read it — including one added by a pull request if your settings are careless. Rotating it is a manual chore, so in practice nobody does.

Federation replaces that with a token GitHub mints for a specific workflow run, valid for minutes, exchangeable only for a role you explicitly allowed. Nothing long-lived is stored anywhere.

The short version: GitHub signs a JWT describing the run (repository, branch, environment). AWS is configured to trust GitHub's OIDC issuer and to hand out a role only when the claims in that JWT match a condition you wrote.

How OIDC federation works

Three things have to line up. AWS needs to trust the issuer (token.actions.githubusercontent.com). A role needs a trust policy that accepts tokens from that issuer and constrains which repository and ref may assume it. The workflow needs id-token: write permission so it can request the token in the first place.

Miss any one of the three and you get an AssumeRoleWithWebIdentity failure that does not tell you which one.

Step 1 — register the identity provider

One per AWS account. In Terraform:

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
Note: AWS now validates GitHub's certificate chain against its own trust store, so the thumbprint is effectively vestigial — but the argument is still required by the provider. Do not build alerting around it changing.

Step 2 — the role and its trust policy

This is where the security actually lives. The condition block is what stops any other GitHub repository on the internet from assuming your role.

data "aws_iam_policy_document" "trust" {
  statement {
    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"]
    }

    # Scope to one repo AND one ref. Do not use a bare wildcard here.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:abdurahim50/zen-infra:ref:refs/heads/main"]
    }
  }
}

For workflows gated behind a GitHub Environment, the sub claim changes shape — it becomes repo:OWNER/REPO:environment:NAME. That is worth preferring, because environments give you required reviewers on top of the branch constraint.

Triggersub claim
Push to mainrepo:OWNER/REPO:ref:refs/heads/main
Tagrepo:OWNER/REPO:ref:refs/tags/v1.2.3
Environmentrepo:OWNER/REPO:environment:production
Pull requestrepo:OWNER/REPO:pull_request

Step 3 — the workflow

permissions:
  id-token: write   # required to request the OIDC token
  contents: read

jobs:
  plan:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<ACCOUNT-ID>:role/gha-zen-infra
          aws-region: us-east-1
          role-session-name: gha-${{ github.run_id }}

      - run: aws sts get-caller-identity

Setting role-session-name to something containing the run ID is worth the extra line: CloudTrail then tells you exactly which workflow run made each API call.

Two mistakes I made

1. Forgetting permissions: at job level

I set id-token: write at the top of the workflow, then added a permissions: block to one job for something unrelated. Job-level permissions replace the workflow-level block entirely rather than merging with it, so that job silently lost the ability to request a token. The error surfaced as a generic credentials failure several steps later.

2. Wildcarding the sub claim "temporarily"

While debugging I relaxed the condition to repo:abdurahim50/zen-infra:*. That works — and it also means a pull request from a fork can assume a role with Terraform apply permissions. I caught it on review the next day. If you need a wildcard while debugging, use StringLike with an explicit prefix and put a reminder in the PR description to remove it.

A trust policy is the actual security boundary here. The IAM permissions attached to the role only matter once someone has already been allowed to assume it.

Verifying it actually worked

  1. Run the workflow and confirm aws sts get-caller-identity returns an ARN of the form assumed-role/gha-zen-infra/gha-<run-id>.
  2. Delete the old AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY repository secrets. If the pipeline still passes, nothing was quietly depending on them.
  3. Deactivate — then delete — the underlying IAM user's access keys. Deactivating first gives you a reversible step and a clean CloudTrail signal if something still needs them.
  4. Open a branch that is not main and confirm the assume-role call fails. A control you have not seen fail is a control you have not tested.
Where this went next: the same pattern covers pushing to ECR and updating the GitOps repository, which meant the entire delivery path for Zen Pharma ended up with zero long-lived cloud credentials in it.

Corrections welcome — email me.