Stop storing AWS access keys in GitHub Secrets. There’s a better way.
GitHub Actions supports OIDC (OpenID Connect) federation, which means your workflows can assume IAM roles directly – no long-lived credentials, no rotation headaches, no secrets to leak.
Here’s how it works and how to set it up properly.
Code Repository: All code from this post is available at github.com/moabukar/blog-code/github-actions-oidc
The Problem with Access Keys
Traditional CI/CD authentication looks like this:
- Create IAM user
- Generate access key
- Store in GitHub Secrets
- Hope nobody leaks them
- Forget to rotate them
- Get breached
Access keys are:
- Long-lived – valid until you delete them
- Static – same credentials for every workflow run
- Broadly scoped – often over-permissioned “just to make CI work”
- Hard to audit – which workflow used these keys when?
OIDC: The Better Way
With OIDC federation:
- GitHub Actions requests a short-lived token from GitHub’s OIDC provider
- Your workflow presents this token to AWS
- AWS validates the token against GitHub’s public keys
- AWS issues temporary credentials (15 min - 1 hour)
- Workflow runs with those credentials
- Credentials automatically expire
No secrets stored. No keys to rotate. Every workflow run gets unique, short-lived credentials.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ GitHub Actions │────►│ GitHub OIDC │────►│ AWS │
│ Workflow │ │ Provider │ │ IAM Role │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ 1. Request token │ │
│──────────────────────►│ │
│ │ │
│ 2. JWT with claims │ │
│◄──────────────────────│ │
│ │ │
│ 3. AssumeRoleWithWebIdentity │
│──────────────────────────────────────────────►│
│ │ │
│ 4. Temporary credentials │
│◄──────────────────────────────────────────────│
Setting It Up
Step 1: Create the OIDC Provider in AWS
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Or with Terraform:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
Step 2: Create the IAM Role
This is where the magic happens. The trust policy controls which GitHub repos/branches can assume the role:
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:myorg/myrepo:*"]
}
}
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}
resource "aws_iam_role_policy_attachment" "github_actions" {
role = aws_iam_role.github_actions.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess" # Scope this down!
}
Step 3: Configure Your Workflow
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-west-1
- name: Deploy
run: |
aws s3 sync ./dist s3://my-bucket
That’s it. No AWS_ACCESS_KEY_ID. No AWS_SECRET_ACCESS_KEY. Just the role ARN.
Token Claims: The Security Controls
The GitHub OIDC token contains claims that you can use in IAM trust policies. This is where you lock things down:
| Claim | Example | Use Case |
|---|---|---|
sub | repo:myorg/myrepo:ref:refs/heads/main | Restrict to specific repo/branch |
repository | myorg/myrepo | Match repository name |
repository_owner | myorg | Match organization |
ref | refs/heads/main | Match branch/tag |
environment | production | Match GitHub environment |
job_workflow_ref | myorg/myrepo/.github/workflows/deploy.yml@refs/heads/main | Match specific workflow file |
actor | octocat | Match user who triggered |
event_name | push | Match trigger event |
Restricting by Repository
Only allow a specific repo:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
}
}
}
Restricting by Branch
Only allow main branch:
{
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
}
}
}
Restricting by Environment
Only allow the production GitHub environment:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
}
}
}
This is powerful – you can require manual approval in GitHub before the role can be assumed.
Restricting by Organization
Allow any repo in your org:
{
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/*:*"
}
}
}
Common Patterns
Different Roles per Environment
# Production role - only main branch
resource "aws_iam_role" "github_actions_prod" {
name = "github-actions-prod"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
"token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:environment:production"
}
}
}]
})
}
# Staging role - any branch
resource "aws_iam_role" "github_actions_staging" {
name = "github-actions-staging"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:*"
}
}
}]
})
}
Workflow with Environment Gates
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-staging
aws-region: eu-west-1
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Requires manual approval
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-prod
aws-region: eu-west-1
Debugging OIDC Issues
”Not authorized to perform sts:AssumeRoleWithWebIdentity”
Check your trust policy conditions. Print the token claims to see what you’re actually getting:
- name: Debug OIDC token
run: |
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
echo "Token claims:"
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
“Audience validation failed”
Make sure your IAM trust policy checks for sts.amazonaws.com:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
“Subject claim mismatch”
The sub claim format varies based on the trigger:
- Push:
repo:org/repo:ref:refs/heads/branch - PR:
repo:org/repo:pull_request - Environment:
repo:org/repo:environment:name
Use StringLike with wildcards if needed.
Beyond AWS
OIDC works with other clouds too:
Azure
- uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
GCP
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github
service_account: github-actions@my-project.iam.gserviceaccount.com
HashiCorp Vault
- uses: hashicorp/vault-action@v2
with:
url: https://vault.example.com
method: jwt
role: github-actions
jwtGithubAudience: https://vault.example.com
Summary
| Approach | Credentials | Lifetime | Rotation | Blast Radius |
|---|---|---|---|---|
| Access Keys | Static | Indefinite | Manual | High |
| OIDC | Dynamic | 15-60 min | Automatic | Low |
OIDC is:
- More secure – no long-lived credentials
- Easier to audit – every workflow run has unique credentials
- Granular – control access by repo, branch, environment
- Zero maintenance – no keys to rotate
Stop storing AWS keys in GitHub. OIDC has been stable since 2021. There’s no excuse.
Further reading: GitHub OIDC docs and AWS federation guide.