Rotating leaked AWS credentials is a painful, stressful exercise. The cause is usually the same: a long-lived AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored in GitHub Secrets, forgotten for years, scoped too broadly. OIDC-based authentication removes the credential entirely - there is nothing to leak.
This is not new technology. GitHub added OIDC support in 2021. AWS added GitHub as a supported provider around the same time. But misconfigured OIDC trust policies create their own class of vulnerability, and most tutorials skip past the hardening. Let’s do it properly.
How GitHub Actions OIDC Works
When a workflow job runs, GitHub’s OIDC provider issues a short-lived JSON Web Token (JWT) signed with GitHub’s private key. The workflow exchanges this token with AWS STS via AssumeRoleWithWebIdentity. AWS validates the token signature against GitHub’s public JWKS endpoint and evaluates the IAM trust policy. If it matches, STS returns temporary credentials scoped to the role - valid for the duration of the job, then gone.
GitHub Runner → GitHub OIDC Provider → JWT
JWT → AWS STS AssumeRoleWithWebIdentity
STS validates JWT signature + trust policy conditions
STS returns temporary credentials (valid 1 hour)
Runner uses credentials → credentials expire
No stored secret. No rotation required. Credentials can’t be reused after the job ends.
Setting Up the IAM OIDC Provider
First, add GitHub as a trusted OIDC provider in your AWS account. This only needs to be done once per account.
Terraform:
data "tls_certificate" "github_actions" {
url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github_actions.certificates[0].sha1_fingerprint]
}
AWS CLI (if not using Terraform):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Creating the IAM Role with a Hardened Trust Policy
This is where most guides go wrong. A trust policy that only checks the audience (sts.amazonaws.com) lets any GitHub repository in the world assume your role if they know your account ID and role ARN.
Insecure (don’t use this):
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
Correct - lock to your org and repo:
data "aws_iam_policy_document" "github_actions_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
# Lock to specific repo and branch
values = ["repo:your-org/your-repo:ref:refs/heads/main"]
}
}
}
The sub claim is where you enforce scope. Common patterns:
# Only main branch
repo:org/repo:ref:refs/heads/main
# Any branch (broader - use only if needed)
repo:org/repo:ref:refs/heads/*
# Specific environment (requires environment protection rules)
repo:org/repo:environment:production
# Pull requests (for read-only roles)
repo:org/repo:pull_request
For production deployments, use the environment constraint and configure environment protection rules in GitHub. This requires a reviewer approval before the job can run.
The Workflow
name: Deploy
on:
push:
branches: [main]
permissions: {}
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for OIDC
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
role-session-name: github-deploy-${{ github.run_id }}
- name: Deploy
run: aws s3 sync ./dist s3://${{ secrets.S3_BUCKET }}
Key details:
permissions: {}at workflow level, grant onlyid-token: writeat job level- Pin action SHAs, not version tags (more on this below)
role-session-namewith the run ID makes CloudTrail logs traceable per workflow run
Scoping the Role’s Permissions
The role should have the minimum permissions needed for the specific job. A deployment role is not a developer role.
resource "aws_iam_role_policy" "deploy" {
name = "deploy-policy"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
]
Resource = [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*",
]
},
{
Effect = "Allow"
Action = ["cloudfront:CreateInvalidation"]
Resource = "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
}
]
})
}
Never attach AdministratorAccess or PowerUserAccess to a GitHub Actions role. If a workflow is compromised, the blast radius is limited to what the role can do.
Auditing Existing Credentials
To find any remaining long-lived access keys in your GitHub organisation:
# List all org secrets (requires admin scope)
gh secret list --org your-org
# For each repo, check if AWS_ACCESS_KEY_ID exists
gh api /orgs/your-org/repos --paginate --jq '.[].name' | \
xargs -I{} gh secret list --repo your-org/{}
For actual key rotation, use IAM Access Analyzer or the AWS Credential Report to identify all active access keys and when they were last used.
The Payoff
After migrating to OIDC:
- No credentials to rotate, audit, or accidentally commit
- CloudTrail logs show exactly which workflow run assumed the role
- A compromised workflow run gets credentials valid for one job, not forever
- You can revoke access by modifying the IAM trust policy, not hunting down secrets in every repo
The setup takes about 30 minutes the first time. The ongoing maintenance cost is essentially zero.