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.
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.
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
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 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.
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Static service tokens leaked into logs and never rotated. SPIFFE identities plus SPIRE-issued SVIDs gave us short-lived certs and killed the shared-secret sprawl.
We moved 40 services off the nginx Ingress controller onto Gateway API without a single dropped connection. Here's the routing overlap trick that made it boring.
Explore more articles in this category
We ran secrets three different ways across AWS, GCP, and Vault. External Secrets Operator gave us one Kubernetes-native workflow. Here's the setup and the gotchas.
Moving our fleet from x86 to Graviton promised 20% savings. We got 31%, but only after fixing native dependencies, a broken base image, and one nasty perf regression.
A p99 that jumped to 3.4 seconds during traffic ramps turned out to be cold starts. Here's how we measured them properly and cut the tail, with real init timings.
Evergreen posts worth revisiting.