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.
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"]
}
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.
| Trigger | sub claim |
|---|---|
| Push to main | repo:OWNER/REPO:ref:refs/heads/main |
| Tag | repo:OWNER/REPO:ref:refs/tags/v1.2.3 |
| Environment | repo:OWNER/REPO:environment:production |
| Pull request | repo: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
- Run the workflow and confirm
aws sts get-caller-identityreturns an ARN of the formassumed-role/gha-zen-infra/gha-<run-id>. - Delete the old
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYrepository secrets. If the pipeline still passes, nothing was quietly depending on them. - 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.
- Open a branch that is not
mainand confirm the assume-role call fails. A control you have not seen fail is a control you have not tested.
Corrections welcome — email me.