OIDC Federation for GitHub Actions to AWS: Killing Long-Lived Keys
We rotated a leaked AWS access key that a workflow had committed to logs. Switching GitHub Actions to OIDC federation meant no static AWS keys exist to leak in the first place.
Key takeaways
- We rotated a leaked AWS access key that a workflow had committed to logs.
- Switching GitHub Actions to OIDC federation meant no static AWS keys exist to leak in the first place.
OIDC Federation for GitHub Actions to AWS: Killing Long-Lived Keys#
The Slack alert came from GitGuardian: an AWS access key had shown up in a public fork's Actions log. It was ours. A workflow had set -x enabled and echoed the environment, dumping AWS_SECRET_ACCESS_KEY into a build log that a contributor's fork made public. We rotated it within the hour, but the real problem wasn't that one key. It was that we had long-lived AWS credentials sitting in GitHub Secrets across 40 repositories, each one a rotation chore and a leak waiting to happen.
OIDC federation removes the credential entirely. GitHub already issues every workflow run a signed identity token. AWS can be taught to trust that token and hand back temporary STS credentials directly. No static key exists, so there's nothing to leak, rotate, or commit by accident.
How the trust actually works#
The flow has three moving parts. GitHub's OIDC provider issues a JWT for the running workflow. You register that provider as an identity provider in AWS IAM. Then you create a role whose trust policy says "GitHub's OIDC provider may assume me, but only for these specific repos and branches." At runtime the workflow presents its token, STS validates the signature and the claims, and returns 15-minute credentials.
First, register the provider once per AWS account:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
The trust policy is where people get burned#
This is the part that matters, because getting it wrong means any GitHub repo on the internet can assume your role. The trust policy must pin the sub claim to your specific repository and, ideally, a specific branch or environment:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::0123456789: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:devopsness/deployer:ref:refs/heads/main"
}
}
}]
}
The sub condition is doing the security work. repo:devopsness/deployer:ref:refs/heads/main means only the main branch of that one repo can assume this role. A common and dangerous mistake is using StringLike with repo:devopsness/*:*, which grants every repo in your org, on every branch, including pull requests from forks. Use StringEquals and be specific. If you need multiple branches, list them explicitly rather than wildcarding.
For deploys gated on GitHub Environments, scope to the environment instead: repo:devopsness/deployer:environment:production. That ties the role to a protected environment with its own approval rules.
The workflow side#
The workflow needs one permission block and the AWS credentials action. No secrets:
permissions:
id-token: write # lets the runner request the OIDC token
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::0123456789:role/gha-deployer
aws-region: eu-west-1
- run: aws s3 sync ./dist s3://devopsness-web/
The id-token: write permission is easy to forget and produces a confusing Error: Credentials could not be loaded if missing. That's the first thing to check when it fails. There are zero AWS_ACCESS_KEY_ID references anywhere. The action exchanges the OIDC token for STS credentials that live for the duration of the job.
What we saw after switching#
Across the 40 repos, we deleted every AWS access key stored in GitHub Secrets. That's 40 credentials that no longer exist. The credentials a workflow now uses last 15 minutes to an hour and are scoped to exactly one role. When a contributor's fork tried to run our deploy workflow, it failed at the assume-role step because a fork's sub claim doesn't match refs/heads/main, which is precisely the behavior you want.
One thing to watch: STS default session duration is an hour, but if a job runs longer, the credentials expire mid-run. For a slow Terraform apply, set role-duration-seconds explicitly, up to the role's MaxSessionDuration.
The call we'd make#
If your CI runs on GitHub Actions and deploys to AWS, there is no good reason left to use static access keys. OIDC federation is a one-time setup per account and it deletes an entire category of incident: leaked keys, forgotten rotations, over-privileged CI credentials sitting in secrets. The only real work is writing the trust policy carefully, and the rule there is simple. Use StringEquals, pin the exact repo and branch or environment, and never reach for a wildcard because it's convenient. A wildcard sub is how you accidentally let the whole internet into your account. For a step-by-step walkthrough of the setup itself, including the common pitfalls that trip people up, see keyless deploys to AWS with GitHub Actions OIDC.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Distroless Docker Images: Smaller, Safer Production Containers
Our node image shipped 240 CVEs, most from OS packages we never called. Moving to distroless dropped the count to single digits and cut image size by 70%.
Agent Memory: Short-Term, Long-Term, and When You Need Neither
Most agents that "need memory" actually need a smaller context window and a database. Here's how we cut a support agent's token bill by 60 percent by deleting memory.
More from Cloud
Explore more articles in this category
Azure OpenAI's Sweden Central Outage: A Health Check Postmortem
A slow database made a backend service look dead, and the health check that was supposed to protect it killed it faster. The lesson has nothing to do with AI.
GKE Pod Snapshots: Cold Starts Drop 89%, If Your Nodes Match
Google's benchmarks show a 70B model restoring in 37 seconds instead of minutes. The catch is a hash and a hardware match that silently refuses to restore when either is off.
AWS Lost a Region for Good: Multi-AZ Is Not Disaster Recovery
AWS says it cannot restore data held only in Bahrain (me-south-1) or in one UAE zone. Multi-AZ gave availability, not recovery, and only cross-region copies survived.
You might have missed
Evergreen posts worth revisiting.