Storing AWS access keys in GitHub secrets is a security liability. Keys don't expire, can be leaked, and are difficult to rotate. There's a better way: OpenID Connect (OIDC) authentication.
With OIDC, GitHub Actions requests temporary credentials from AWS for each workflow run. Nothing long-lived, nothing stored, nothing to rotate.
How OIDC Authentication Works
The flow involves three parties: GitHub, AWS, and your workflow.
Step 1: Your workflow requests a JWT token from GitHub's OIDC provider. This token contains claims about the workflow - the repository, branch, actor, and more.
Step 2: The workflow presents this token to AWS Security Token Service (STS).
Step 3: AWS validates the token against the OIDC provider configuration. If the claims match the IAM role's trust policy, AWS returns temporary credentials.
Step 4: The workflow uses these credentials (valid for 1 hour by default) to interact with AWS.
No long-lived secrets involved.
Setting Up the OIDC Provider
First, create an OIDC identity provider in AWS. This establishes the trust relationship with GitHub:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# GitHub's OIDC provider thumbprint
# This is stable and provided by GitHub
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
tags = {
Name = "GitHub Actions OIDC"
ManagedBy = "OpenTofu"
}
}The thumbprint is required by the API, but AWS no longer uses it for validation when the OIDC provider URL uses a certificate from a trusted CA (which GitHub does). It's effectively a no-op, but you still need to provide it.
Creating the IAM Role
The IAM role defines what GitHub Actions can do. The trust policy controls which repositories can assume it:
resource "aws_iam_role" "github_actions" {
name = "${var.organization_name}-github-actions"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/*:*"
}
}
}
]
})
}Understanding the Trust Policy
The Condition block is critical for security:
token.actions.githubusercontent.com:aud: Must equal "sts.amazonaws.com". This ensures the token was intended for AWS.
token.actions.githubusercontent.com:sub: The subject claim. Format is repo:OWNER/REPO:REF. We use a wildcard (repo:myorg/*:*) to allow any repository in the organization.
This broad trust is intentional for early bootstrap flexibility. As repositories and pipelines stabilise, tighten it to specific repositories and protected refs/environments.
Field note from implementation: we started with an org-wide wildcard while the repository split was still moving, then narrowed trust once deployment paths were stable.
| Stage | sub scope | Trade-off |
|---|---|---|
| Early bootstrap | repo:org/*:* | Fast setup, wider blast radius |
| Steady state | repo:org/repo:ref:... or environment-bound | Better isolation, more policy maintenance |
You can be more restrictive:
# Specific repository only
"repo:myorg/aws-bootstrap:ref:refs/heads/main"
# Any branch in specific repo
"repo:myorg/aws-bootstrap:*"
# Pull requests in specific repo
"repo:myorg/aws-bootstrap:pull_request"Attaching Permissions
The role needs permissions to do its job. We use a combination of managed policies and custom policies depending on the use case.
For the bootstrap role (which creates infrastructure), we initially used AdministratorAccess:
resource "aws_iam_role_policy_attachment" "github_admin" {
role = aws_iam_role.github_actions.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}In our implementation this was explicitly temporary and tracked as a removal item. Exit criteria were simple: scoped deployment roles existed in each target account and pipelines were green without admin rights.
This works for bootstrapping, but it violates least privilege. A better approach is scoped deployment roles (covered in Part 8).
Configuring the GitHub Workflow
The workflow needs permission to request OIDC tokens:
name: Deploy Infrastructure
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read # Required for checkout
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/myorg-github-actions
aws-region: eu-west-2
- name: Verify AWS access
run: aws sts get-caller-identityThe permissions.id-token: write is essential. Without it, GitHub won't issue OIDC tokens.
Environment-Specific Roles
For different environments, use different roles with different permissions:
jobs:
deploy-staging:
environment: staging
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_STAGING_ROLE_ARN }}
aws-region: eu-west-2
deploy-production:
environment: production
needs: deploy-staging
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PRODUCTION_ROLE_ARN }}
aws-region: eu-west-2The role ARN is still stored as a secret, but it's not sensitive - knowing the ARN doesn't grant access. Only workflows matching the trust policy can assume the role.
Cross-Account Access
With OIDC credentials in hand, the next challenge is reaching other accounts. The OIDC role in the management account can chain to scoped deployment roles in member accounts - the workflow assumes the OIDC role first, then assumes a second role in the target account.
This role chaining pattern, including trust policies, external ID protection, and the deployment role design, is covered in detail in Part 8.
Limiting Token Lifetime
Temporary credentials default to 1 hour. You can reduce this:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: eu-west-2
role-duration-seconds: 900 # 15 minutesShorter durations reduce the window if credentials are somehow exposed during the run.
Debugging OIDC Issues
When OIDC authentication fails, the error messages can be cryptic. These are the two failures I see most often:
| Error | Usually means | What to check |
|---|---|---|
"Not authorized to perform sts:AssumeRoleWithWebIdentity" | Token claims do not satisfy the role trust policy | Trust policy conditions vs actual claims, permissions.id-token: write, OIDC provider thumbprint |
"The specified resource does not exist" | Referenced IAM/OIDC resource is wrong or missing | Role ARN value, existence of IAM OIDC provider |
To see the actual claims in your token:
- name: Debug OIDC token
run: |
TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r '.value')
echo $TOKEN | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .This decodes the JWT and shows the claims AWS will evaluate.
Why This Matters
With OIDC in place, credentials are fresh every run, there's nothing stored that can leak, and CloudTrail shows exactly which workflow assumed which role. Trust policies can limit access down to specific repositories, branches, and environments.
Every new repository should use OIDC. Long-lived AWS keys should be the exception, not the starting point.