Keyless GitHub Actions to AWS: OIDC Role Chaining Across Accounts
Every tutorial on GitHub Actions + AWS OIDC shows the same thing: one role, one account. That’s fine for a side project. But if you’re running a multi-account AWS organization — dev, prod, tooling, security — you need role chaining.
Here’s the pattern I use across 25+ repos deploying into multiple AWS accounts with zero stored secrets.
The Problem
You have a multi-account AWS org managed by Control Tower. Each workload account has its own resources. GitHub Actions needs to deploy into each account, but you don’t want:
- IAM users with static credentials
- Secrets rotated across 25 repos
- A single overpowered role that can touch every account
The Architecture
GitHub Actions (OIDC)
│
▼
TerraformCIRole (management account)
│
▼ sts:AssumeRole
GitHubCIRole (workload account)
│
▼
Deploy infrastructure
Three layers:
- OIDC Provider in the management account trusts GitHub’s token endpoint
- TerraformCIRole in the management account — assumed via OIDC, scoped to your GitHub org
- GitHubCIRole in each workload account — trusts only TerraformCIRole
Step 1: OIDC Provider + TerraformCIRole
In your management account Terraform:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# AWS ignores thumbprints for GitHub's OIDC provider (uses intermediate CA
# validation instead), but the field is still required by the API.
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
data "aws_iam_policy_document" "github_actions_assume" {
statement {
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:your-org/*:ref:refs/heads/main",
"repo:your-org/*:pull_request",
]
}
}
}
resource "aws_iam_role" "terraform_ci" {
name = "TerraformCIRole"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
}
Security note on the org wildcard: repo:your-org/* trusts every repo in your GitHub org. This is a pragmatic tradeoff — it keeps you under the IAM 2048-byte policy limit as repos grow. But it means any repo in the org (including forks, if your org allows them) can assume this role. If you need tighter control, enumerate specific repos instead. The wildcard is acceptable here because the management role itself has limited permissions — the real access comes from the workload role in the next step.
Step 2: GitHubCIRole in Workload Accounts
Deploy this to every workload account via AFT global customizations (or any account baseline):
data "aws_iam_policy_document" "github_ci_assume" {
statement {
actions = ["sts:AssumeRole", "sts:TagSession"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::MANAGEMENT_ACCOUNT_ID:role/TerraformCIRole"]
}
}
}
resource "aws_iam_role" "github_ci" {
name = "GitHubCIRole"
assume_role_policy = data.aws_iam_policy_document.github_ci_assume.json
}
resource "aws_iam_role_policy_attachment" "github_ci" {
role = aws_iam_role.github_ci.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
This role only trusts the management account’s TerraformCIRole. No OIDC provider needed in workload accounts.
On AdministratorAccess: I use broad permissions here because CI deploys full infrastructure stacks — IAM roles, S3 buckets, CloudFront distributions, Route 53 records. Scoping down to individual services would require maintaining a permissions list that mirrors the infrastructure, which drifts. The security boundary is the trust policy (only TerraformCIRole can assume this), not the permissions policy. For production environments with compliance requirements, consider scoped policies per workload instead.
Step 3: GitHub Actions Workflow
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::MANAGEMENT_ACCOUNT_ID:role/TerraformCIRole
aws-region: us-east-1
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::WORKLOAD_ACCOUNT_ID:role/GitHubCIRole
aws-region: us-east-1
role-chaining: true
- run: aws sts get-caller-identity # Now in the workload account
The key is role-chaining: true on the second step. It uses the credentials from step 1 to assume into the workload account.
Why This Works
- No secrets stored anywhere. GitHub’s OIDC token is ephemeral.
- Blast radius isolation. Each account has its own role. Compromise one workflow, you get one account.
- Centralized trust. One OIDC provider, one trust policy. Adding a new repo doesn’t require touching workload accounts.
- Auditable. CloudTrail shows the full chain: GitHub → TerraformCIRole → GitHubCIRole → action.
Gotchas
Session duration: Role chaining limits you to 1 hour max session. If your deploys take longer (CloudFront distributions, ACM certificate validation waiting on DNS), you’ll get SignatureExpired errors. Split long-running operations into separate jobs or separate applies.
sts:TagSession: The configure-aws-credentials action requires this permission on the trust policy. Without it, you get cryptic “Access Denied” errors that don’t mention tagging at all. I wasted hours on this the first time.
OIDC sub format: workflow_dispatch events use ref:refs/heads/main, same as push. But pull_request uses a different format — make sure your trust policy includes both patterns.
What happens when it breaks: If the OIDC assume fails, check three things in order: (1) the trust policy sub condition matches the event type, (2) the OIDC provider exists in the correct account, (3) the role ARN is correct. If the chain fails, verify sts:TagSession is in the workload role’s trust policy. CloudTrail in the management account will show the exact denial reason.
The Result
25+ repos deploying into multiple AWS accounts. Zero stored credentials. One OIDC provider. One trust policy to manage. Sleep well at night.