Retire the AWS keys in your GitHub secrets. OIDC, in one sitting.
Every repository I inherit has the same two secrets in it: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Nobody remembers which IAM user they belong to. The access key was created during a deploy setup in some previous year, its rotation date has been aspirational ever since, and the one thing everybody agrees on is that rotating it might break the deploy, so it does not get rotated. That is not a credential. That is a liability with a name.
GitHub Actions can drop both secrets entirely. The workflow asks GitHub for a short-lived signed token, AWS trusts GitHub as an identity provider, and the run assumes a role for an hour. Nothing long-lived exists to leak, rotate or mourn. Setup is fifteen minutes of IAM, once.
The setup
- Register the identity provider — once per AWS account: an OIDC provider for
token.actions.githubusercontent.comwith audiencests.amazonaws.com. The console does it in four clicks. - Create the role the workflow will assume. Attach your least-privilege deployment policy to it — this part you were doing anyway.
- Write the trust policy, which is where all the security lives:
{
"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",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
}- Point the workflow at it:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: eu-west-1
- run: aws sts get-caller-identityThe permissions block is not optional — without id-token: write the workflow cannot ask for a token, and the failure message will not tell you that is why.
The sub claim is the whole security model
The sub condition is what stops every other GitHub customer from assuming your role, so it deserves respect. The format is exact and case-sensitive: repo:ORG/REPO:ref:refs/heads/main for branch pushes, repo:ORG/REPO:pull_request for pull request events, and repo:ORG/REPO:environment:production when you deploy from a GitHub environment — which is the pattern I prefer for production, because the environment protection rules (approvers, branch restrictions) then sit in front of the role.
The trap is StringLike with a wildcard. repo:my-org/* is five characters of convenience and a blast radius that includes the next repo somebody creates in the org. Keep the condition exact; add roles when you add repos.
The honest trade-offs
- Credentials last about an hour. A deploy longer than that needs a re-assume step, which is a feature wearing a minor inconvenience costume.
- Everything that is not GitHub Actions still needs its own identity — local dev, other CI, that cron box. OIDC removes the repo key, not the account’s key hygiene problem. (The EKS equivalent is Pod Identity or IRSA.)
- When the trust policy and the token disagree, AWS says
AccessDeniedand nothing else. The debugging move is to decode the token and compare claims character by character — thesubmismatch is nearly always a ref format you did not expect, like thepull_requestvariant arriving from a fork. - Renamed repositories keep their old sub strings working until they do not. When a repo is renamed, update the trust policy the same day, or the next person to touch it inherits a mystery.
Where I landed
No new repository gets static keys — the template has the OIDC wiring in it. The existing repos migrate the next time their key would have needed rotating, which is now never. The account has exactly as many long-lived AWS credentials as it has humans, and none of them live in a secrets drawer.