I rotated a leaked AWS access key at 2 AM last year. A contractor had pushed a workflow that printed environment variables for "debugging," GitHub's secret scanner caught it about four minutes later, and by the time I'd revoked the key and audited CloudTrail, I'd lost an hour of sleep I still resent.
That was the night I went all-in on OIDC for GitHub Actions. If you're still using long-lived AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in your repo secrets, this post is for you. I'll walk through what OIDC actually does, why static keys are a rake to be stood on, and exactly how to wire it up — IAM trust policy, workflow YAML, all of it.
The Problem With Static Access Keys
Let's be honest about what an AWS access key in GitHub Secrets really is: a permanent credential sitting in a system you don't fully control.
A few things go wrong in practice:
- They never rotate. I've audited orgs with five-year-old keys still in use. Nobody wants to be the person who breaks a deploy by rotating the wrong one.
-
They leak. Logs, screenshots, a misconfigured
set -x, a fork's PR workflow — the surface area is huge. -
They're over-scoped. Most teams attach
PowerUserAccessand move on, because writing tight IAM policies is annoying and "we'll fix it later." - They have no context. When a key is used, CloudTrail sees an IAM user. It can't tell you which repo, which workflow, which commit triggered the call.
Static keys treat your CI/CD pipeline like a trusted human user. It isn't. It's a robot that runs whatever YAML lands in main.
What OIDC Actually Is
OpenID Connect is just an identity layer on top of OAuth 2.0. The piece that matters for us: GitHub Actions can hand out short-lived JWT tokens that prove a workflow is running, and AWS knows how to verify those tokens and trade them for temporary credentials.
The flow looks like this:
- Your workflow runs and requests an OIDC token from GitHub.
- GitHub mints a JWT signed with its keys. The token's claims include the repo, branch, environment, and workflow that requested it.
- The workflow sends that JWT to AWS STS via
AssumeRoleWithWebIdentity. - AWS validates the signature against GitHub's public keys, checks the claims against your IAM role's trust policy, and returns temporary credentials (typically 1 hour).
- Your workflow uses those credentials. They expire. Nothing to rotate.
No long-lived secret ever leaves AWS. No static key ever enters GitHub. And every API call in CloudTrail is tied to a specific workflow run.
Setting Up OIDC for AWS
There are two pieces: an identity provider in IAM (one-time setup per AWS account), and an IAM role with a trust policy that scopes who can assume it.
Step 1: Create the OIDC Provider in IAM
You only do this once per AWS account. Either click through the IAM console or use Terraform/CLI:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
AWS now also accepts the provider without an explicit thumbprint for token.actions.githubusercontent.com, but I still pass one for explicitness.
Step 2: Write a Trust Policy That Actually Constrains Things
This is where most tutorials fall apart. They show you a trust policy with "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*" and call it done. Don't do that. A wildcard :* on the sub claim means any branch, any tag, any PR from a fork can assume your role.
Here's a trust policy I'd actually deploy. It pins the role to a specific repo and only allows it to be assumed from the main branch or from a production environment:
{
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org/my-repo:environment:production"
]
}
}
}
]
}
A few things worth flagging:
- The
audcheck prevents tokens issued for other audiences from being accepted. Always include it. - The
subclaim is the lever. You can pin to branches (ref:refs/heads/main), tags (ref:refs/tags/v*), pull requests (pull_request), or environments (environment:production). I use environments for production roles because they integrate with GitHub's required-reviewer gates. - For staging or per-PR ephemeral environments, I create a separate role with a looser sub pattern and weaker permissions. Don't use one role for everything.
Attach whatever permissions policy your workflow actually needs. Be tight here too — s3:PutObject on one bucket beats s3:* every time.
Step 3: The Workflow YAML
Two things have to happen in the workflow itself: you need to grant the job permission to mint an OIDC token, and you need to use aws-actions/configure-aws-credentials@v4 with role-to-assume instead of access keys.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write # required to request the OIDC JWT
contents: read # required for actions/checkout
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
role-session-name: gha-${{ github.run_id }}
aws-region: us-east-1
- name: Deploy
run: |
aws s3 sync ./dist s3://my-app-prod --delete
aws cloudfront create-invalidation \
--distribution-id E1ABCDEFGHIJKL \
--paths "/*"
The permissions block at the job level is the bit people miss. Without id-token: write, the action can't request a token and you'll get a confusing 403. Setting contents: read explicitly is good hygiene because declaring permissions at all switches the job from the default permissive token to a least-privilege one.
I also set role-session-name to include the run ID. That single line has saved me hours during incident review — CloudTrail now shows me exactly which workflow run made every API call.
What This Looks Like in CloudTrail
This is the part that sells it. With static keys, every event has userIdentity.userName: github-actions-ci-user. With OIDC, the assumedRole session name is gha-7384920183, and the underlying userIdentity.sessionContext.sessionIssuer shows the role plus the federated subject — meaning you can grep CloudTrail for a specific workflow run and see every call it made.
When a deploy goes sideways, that traceability is worth its weight.
A Quick Note on Azure
If you're on Azure, the same pattern works — federated credentials on an App Registration plus the azure/login@v2 action:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
The IDs aren't secrets in any meaningful sense (they identify the app, they don't authenticate as it), but GitHub still wants them in the secrets store. No client-secret anywhere. Same story on GCP via Workload Identity Federation.
Migration Tips
If you're moving an existing repo off static keys, here's the order I'd go in:
- Stand up the OIDC provider and a new role alongside the existing IAM user. Don't delete the user yet.
-
Test on a non-production branch first. Create a role scoped to
ref:refs/heads/oidc-testand prove the deploy works end-to-end. - Switch one workflow at a time. Update the YAML, run it, watch CloudTrail.
- Once everything is green for a week, delete the IAM user's access keys. Not the user — just the keys. That way if something breaks you can restore quickly without recreating IAM resources.
- After another week, delete the user.
Don't try to migrate everything in one PR. The blast radius isn't worth it.
Wrapping Up
Static AWS access keys in GitHub Secrets are a 2018 pattern. OIDC takes about 30 minutes to set up properly per repo, eliminates an entire class of credential-leak incidents, and gives you actual auditability when something goes wrong. There's no reason not to make the switch.
I use this OIDC pattern in all 10 workflows in my GitHub Actions Pack — [https://neilwave182.gumroad.com/l/fafaq].
Top comments (0)