GitHub Actions Multi-Account AWS OIDC Role Chaining (2026)
May 30, 2026
TL;DR
Deploying to staging AND production AWS accounts from the same GitHub Actions workflow is a hub-and-spoke problem: one OIDC-trusted role in a "hub" account, one deploy role per workload account, and role-chaining: true in aws-actions/configure-aws-credentials@v6.1.3 to hop between them. This 2026 tutorial wires up the full pattern, including the sts:TagSession permission everyone forgets, the AWS hard cap of one hour on chained sessions, GitHub Environments as a deploy gate, and forward-compat for the immutable subject claim that turns on for new repositories on June 18, 2026.
Answer snippet: Put one OIDC-trusted hub role in a central AWS account, give each workload account a spoke role that trusts the hub with sts:AssumeRole and sts:TagSession, then call aws-actions/configure-aws-credentials@v6.1.3 twice — once via OIDC, once with role-chaining: true. Chained sessions are capped at one hour.
What you'll build
- One hub IAM role in a central AWS account that trusts GitHub Actions OIDC for your repository.
- Two spoke IAM roles —
deploy-staginganddeploy-production— in separate workload accounts that the hub role can assume across the account boundary. - A GitHub Actions workflow that runs
terraform applyagainst EITHER staging or production based on the GitHub Environment chosen by the job. - A spoke trust policy that grants both
sts:AssumeRoleandsts:TagSessionto the hub role — and the alternativerole-skip-session-tagging: trueworkaround for shared-services roles where you cannot grantsts:TagSession. - A
role-duration-seconds: 3600cap baked into the workflow so deploys fail fast instead of dying at the chained-session boundary. - Forward-compat with the immutable subject claim landing for new repositories on 2026-06-18.
About 40–60 minutes end-to-end.
Prerequisites
- Three AWS accounts: one "hub" (the OIDC trust anchor) and two workload accounts ("spokes") for staging and production. The hub does NOT need to be your AWS Organization management account — a dedicated
shared-servicesorsecurity-toolingaccount is the conventional choice. - The single-account version of this tutorial set up and working: GitHub Actions OIDC to AWS: Keyless Terraform (2026). The hub role you build here is the same shape as the single-account role in that post — this tutorial layers chaining on top.
- An S3 backend for Terraform state with native locking — the Terraform S3 native state locking tutorial walks through provisioning one without DynamoDB.
aws-actions/configure-aws-credentials@v6.1.3works onactions/runnerv2.327.1 or later1; GitHub-hostedubuntu-latestrunners shipped past that floor in early 2026, so no override is needed there.- Terraform 1.15.52 and AWS CLI v2.34+3 on the runner (provisioned by
hashicorp/setup-terraform@v4.0.14). - A GitHub repository with
id-token: writeavailable (any plan — OIDC works on Free, Pro, Team, and Enterprise Cloud5).
Step 1: Why hub-and-spoke (and not "one OIDC role per account")
The naïve approach is to register the GitHub OIDC provider in every workload account and create one OIDC-trusted role per account. That works, but it gives you N copies of the same OIDC plumbing to keep in sync — every change to the sub claim format, every new GitHub feature, every audit. It also makes adding a fourth or fifth account a per-account bootstrap.
Hub-and-spoke turns N into 1 + N: one OIDC provider in the hub account, one OIDC-trusted role in the hub account, and a trivially-templated sts:AssumeRole-only role in each spoke. The hub role's trust policy is the only OIDC-aware resource — spoke roles just trust an AWS principal (the hub role's ARN), which is the same boring cross-account pattern AWS has had since 2011.
It also gives you a single audit pane: every deploy across every account starts with the same hub role, and the spoke roles only have to log "did this ARN assume me, and for how long" — a question CloudTrail already answers cleanly.
Trade-off: chained sessions are capped at one hour (Step 6 covers what that means in practice). For a Terraform apply that takes 20 minutes against a few hundred resources, it's not a problem. For a 90-minute infrastructure migration, you'll want a different design.
Step 2: Bootstrap the hub account — OIDC provider and hub role
This runs once in the hub account (we'll call it 123456789012 throughout). The IAM OpenID Connect provider tells AWS to trust JWTs signed by token.actions.githubusercontent.com.
# Run against the hub account
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
Notice there's no --thumbprint-list. Since July 2023 AWS verifies the JWKS endpoint TLS certificate against its bundled root CAs and ignores any thumbprint you pass for the GitHub provider; tutorials that still pin the old 6938fd4d… fingerprint are propagating a pre-2023 ritual that does nothing today6.
Now create the hub role. Save the trust policy as hub-trust.json:
{
"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:your-org/your-repo:*"
}
}
}]
}
The :aud=sts.amazonaws.com pin matches the default audience input of aws-actions/configure-aws-credentials@v6.1.31. The :sub=repo:your-org/your-repo:* StringLike pin restricts the role to JWTs minted for your specific repo (any branch, any environment); Step 5 will tighten this further.
Create the role with a minimal permission policy. The hub role only needs one capability: sts:AssumeRole on the spoke ARNs.
# Hub account
aws iam create-role \
--role-name github-actions-hub \
--assume-role-policy-document file://hub-trust.json \
--max-session-duration 3600
Then attach an inline policy that lists the spoke ARNs it can assume:
cat > hub-permissions.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::222222222222:role/deploy-staging",
"arn:aws:iam::333333333333:role/deploy-production"
]
}]
}
EOF
aws iam put-role-policy \
--role-name github-actions-hub \
--policy-name AssumeSpokes \
--policy-document file://hub-permissions.json
Two design notes. First, the hub role's --max-session-duration is set to 3600 seconds. The default is 3600 anyway, but pinning it explicitly removes the temptation for someone to bump it later and assume that propagates through a chain — it does not (Step 6). Second, the hub role has zero AWS data-plane permissions: it cannot read an S3 object, list an EC2 instance, or talk to RDS. The only blast radius if its credentials leak is "the attacker can try to assume the spoke roles, which themselves restrict by source ARN."
Step 3: Provision spoke roles with sts:TagSession
This is where most multi-account tutorials get it wrong. The spoke trust policy must grant sts:AssumeRole AND sts:TagSession to the hub role; without sts:TagSession, the chained AssumeRole call fails because aws-actions/configure-aws-credentials passes session tags (GitHub, Repository, Workflow, Action, Actor, Commit, Branch) by default, and AWS rejects the call when the tag-passing permission is missing7.
Save this as spoke-trust.json and apply it to BOTH the staging and production accounts:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/github-actions-hub"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
]
}]
}
The Principal is the hub role ARN, NOT the account root. Pinning the principal to the exact role ARN means even if another role somehow gets created in the hub account, it cannot assume your spoke — only github-actions-hub can8. (Some legacy patterns still use "AWS": "arn:aws:iam::123456789012:root". That's strictly less safe and there's no reason to do it.)
Provision both spokes. From the staging account (222222222222):
# Run against the staging spoke account
aws iam create-role \
--role-name deploy-staging \
--assume-role-policy-document file://spoke-trust.json \
--max-session-duration 3600
aws iam attach-role-policy \
--role-name deploy-staging \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
# Replace PowerUserAccess with your least-privilege policy in real life.
Repeat against 333333333333 for deploy-production. The trust policy file is identical; what differs per-spoke is the permission policy (production is usually more locked-down than staging).
If your security team rejects granting sts:TagSession to a cross-account principal, you have one escape hatch — Step 4 shows it.
Step 4: Write the chaining workflow with role-chaining: true
Now the workflow. The whole point of role-chaining: true is summed up in its own description from the action manifest: "Use existing credentials from the environment to assume a new role, rather than providing credentials as input."9 Without the flag, the second configure-aws-credentials step would try sts:AssumeRoleWithWebIdentity against the spoke role — but the spoke role does not trust the OIDC provider; it trusts the hub role's ARN. You'd get this familiar error:
Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity
That error is the #1 sign you forgot the chaining flag10. With role-chaining: true, the second step uses the credentials the first step exported into the runner's environment as the principal calling sts:AssumeRole — the regular cross-account flow.
Save as .github/workflows/deploy.yml:
name: Deploy
on:
workflow_dispatch:
inputs:
target:
description: 'Deploy target'
required: true
default: 'staging'
type: choice
options: [staging, production]
permissions:
id-token: write
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: ${{ inputs.target }}
steps:
- uses: actions/checkout@v6.0.2
- name: Assume hub role via OIDC
uses: aws-actions/configure-aws-credentials@v6.1.3
with:
aws-region: us-east-1
role-to-assume: arn:aws:iam::123456789012:role/github-actions-hub
role-session-name: gh-hub-${{ github.run_id }}
role-duration-seconds: 3600
- name: Chain into the spoke deploy role
uses: aws-actions/configure-aws-credentials@v6.1.3
with:
aws-region: us-east-1
role-to-assume: ${{ inputs.target == 'production'
&& 'arn:aws:iam::333333333333:role/deploy-production'
|| 'arn:aws:iam::222222222222:role/deploy-staging' }}
role-session-name: gh-spoke-${{ inputs.target }}-${{ github.run_id }}
role-chaining: true
role-duration-seconds: 3600
- uses: hashicorp/setup-terraform@v4.0.1
with:
terraform_version: 1.15.5
- run: terraform init
- run: terraform plan -out=tfplan
- run: terraform apply -auto-approve tfplan
A few things worth calling out. The action assumes the hub role using the GitHub OIDC token (the runner-provided ACTIONS_ID_TOKEN_REQUEST_TOKEN exchanged for an STS token), then exports AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN into the runner's environment. The second step has no role-to-assume input pointing back at OIDC — role-chaining: true tells it "the credentials in my environment are already a valid principal; use them to call sts:AssumeRole." The spoke ARN is chosen by a ternary on the target input, and environment: ${{ inputs.target }} ties the job to a GitHub Environment (we'll add protection rules in Step 5).
If you cannot grant sts:TagSession on a spoke (for instance, because your governance pipeline only ships a fixed trust policy template that doesn't include it), set role-skip-session-tagging: true on the chaining step11:
- name: Chain into the shared-services role
uses: aws-actions/configure-aws-credentials@v6.1.3
with:
aws-region: us-east-1
role-to-assume: arn:aws:iam::444444444444:role/shared-services
role-session-name: gh-shared-${{ github.run_id }}
role-chaining: true
role-skip-session-tagging: true
role-duration-seconds: 3600
The trade-off is that the chained session loses the GitHub/Repository/Workflow/Commit/Branch tags — CloudTrail still records the assume-role event, but the workload-account audit can no longer answer "which workflow run did this?" from the session itself. If you go this route, set the session name to something searchable (the workflow above uses gh-shared-<run_id>) and lean on the linkage between the hub-account and spoke-account CloudTrail events instead.
Step 5: Pin spoke roles to GitHub Environments
The hub trust policy in Step 2 allows any branch and any environment in your repository. That's fine for the hub — its blast radius is just "can call AssumeRole on the spokes" — but the spoke roles should be tighter. In particular, the production spoke should only accept a chain that originated from a workflow run that went through a GitHub Environment with the right protection rules (required reviewers, a wait timer, restricted source branches)12.
You cannot do this on the spoke trust policy itself — the spoke trusts the hub role's ARN, not GitHub's OIDC issuer, so it has no access to the token.actions.githubusercontent.com:sub claim. The pinning has to happen at the hub layer, where the OIDC condition keys are visible.
Tighten hub-trust.json to use a StringLike on the subject claim that matches BOTH the environment:staging and environment:production patterns, but nothing else:
{
"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:your-org/your-repo:environment:staging",
"repo:your-org/your-repo:environment:production"
]
}
}
}]
}
The subject claim on a workflow job with environment: production is exactly repo:your-org/your-repo:environment:production (it replaces the branch/ref claim entirely when an environment is set)13. Pushes to feature branches that don't reference an environment cannot mint a JWT that matches either pattern, so they cannot assume the hub role at all — which means they cannot reach either spoke, no matter what they try.
Now go to the repo's Settings → Environments, create staging and production, and on the production environment enable Required reviewers (one or two trusted humans), Wait timer (e.g. 5 minutes), and Deployment branches restricted to main. The job in Step 4 already has environment: ${{ inputs.target }}, so the workflow will block on those rules before the runner even starts minting the OIDC JWT12.
Apply the new trust policy:
aws iam update-assume-role-policy \
--role-name github-actions-hub \
--policy-document file://hub-trust.json
Step 6: The 1-hour cap (and what to do about it)
The most-skipped page of every multi-account AWS tutorial is the one that mentions this: the maximum duration for a chained role session is one hour. Period. The AWS STS AssumeRole API reference is unambiguous — "if you assume a role using role chaining and provide a DurationSeconds parameter value greater than one hour, the operation fails."14 Setting --max-session-duration to 12 hours on the spoke role does not change this. Bumping role-duration-seconds to 7200 in the workflow does not change this either; the call to AssumeRole will return DurationSeconds exceeds the MaxSessionDuration set for this role because for the chained call the effective max is 3600.
For most deploys this is a non-issue: a typical terraform apply over a few hundred resources takes 5–25 minutes. But three things will bite you:
- Long Terraform applies. A 90-minute migration that creates RDS read replicas, runs schema changes, and rotates security groups will hit the cap mid-
apply. The credentials expire, the AWS provider starts getting 401s, and Terraform leaves you with a partial state (lock still held, half the resources changed). The fix is to split the deploy into smallerterraform applyinvocations that each fit inside the hour, OR redesign so the long-running operation does not need fresh credentials (most schema migrations should run inside the application, not inside Terraform). - Async waiters. Some
aws ec2 wait …oraws cloudformation wait stack-update-completecalls poll until a long-running operation finishes. If the waited operation can take more than an hour, the waiter will fail withExpiredTokenand the workflow step will exit non-zero even though the AWS operation eventually succeeded. Use a smaller polling step that exits fast and tracks completion outside the chained session. - Cross-step credentials reuse. If you call
configure-aws-credentialsonce at the start of a job and then have ten subsequent steps that depend on the exported env vars, all ten steps share the one session's clock. A 50-minute test step followed by a 15-minute deploy step will fail at the deploy. Either re-runconfigure-aws-credentialswithrole-chaining: trueat the start of each long-running stage (cheap; re-chains from the hub creds still in env) or split into separate jobs.
Setting role-duration-seconds: 3600 explicitly on both steps in Step 4's workflow is a "fail fast" choice: it tells you up front that you have one hour, instead of pretending you have twelve.
Step 7: Forward-compat for the 2026-06-18 immutable subject claim
GitHub announced the immutable-subject-claim rollout on April 23, 2026. For all repositories created on or after June 18, 2026 (plus any repository rename or transfer happening on or after that date), the OIDC subject claim no longer looks like:
repo:your-org/your-repo:environment:production
It looks like:
repo:your-org-1234567/your-repo-89012345:environment:production
The numeric IDs are appended to defend against name-reuse attacks where a recycled org/repo name could be re-registered and used to mint tokens that match an existing trust policy15. Existing repositories are unaffected unless you opt in, and the change is for github.com only — not GitHub Enterprise Server.
If your repository is older than the rollout, the workflow above keeps working. But you should update the hub trust policy now to accept both the legacy AND the immutable formats, so the day the change lands for your repo (rename, transfer, or because you opt in), nothing breaks at 3 AM:
{
"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:your-org/your-repo:environment:staging",
"repo:your-org/your-repo:environment:production",
"repo:your-org-*/your-repo-*:environment:staging",
"repo:your-org-*/your-repo-*:environment:production"
]
}
}
}]
}
The * glob in StringLike matches the trailing numeric ID without weakening the rest of the path: an attacker who registers a your-org namespace in the future cannot match either pattern because they cannot mint a token whose sub starts with repo:your-org-<your-actual-id>/.
Verification
The fastest sanity check is to trigger the workflow against staging from the GitHub UI (Actions → Deploy → Run workflow → target: staging) and watch both configure-aws-credentials steps log a green checkmark and a masked AWS account ID. Then verify from CloudTrail in each spoke account:
# In the staging spoke account
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
--max-results 5 \
--query 'Events[*].[EventTime,Username,Resources[?ResourceType==`AWS::IAM::Role`].ResourceName | [0]]' \
--output table
You should see an event in the spoke account where the Username (principal) is github-actions-hub/gh-hub-<run_id> (the hub role's session name from Step 4), and the resource is deploy-staging. That's the chain working end-to-end: the OIDC JWT minted the hub session in account 123456789012, and the hub session assumed deploy-staging in 222222222222.
If you flip the target input to production from a PR branch, the run should fail at the "wait for review" stage — not at the IAM layer — because the environment protection rules block it before the runner even tries.
Troubleshooting
Not authorized to perform sts:AssumeRoleWithWebIdentity on the second step. You forgot role-chaining: true on the second configure-aws-credentials step. The action defaulted back to the OIDC flow against the spoke role, which does not trust the OIDC provider10.
User: arn:aws:sts::123456789012:assumed-role/github-actions-hub/... is not authorized to perform: sts:TagSession on resource: arn:aws:iam::222222222222:role/deploy-staging. The spoke trust policy is missing sts:TagSession. Either add it to the spoke trust policy (Step 3's preferred path) or set role-skip-session-tagging: true on the chaining step (Step 4's escape hatch)7.
The requested DurationSeconds exceeds the MaxSessionDuration set for this role at the chaining step, even though you set --max-session-duration 3600 on both roles. You almost certainly set role-duration-seconds higher than 3600 on the chaining step. Chained sessions are capped at one hour regardless of either role's MaxSessionDuration14. Set it to 3600 (or omit it — 3600 is the default).
Tokens that expire 55 minutes in. Your job's wall-clock from "OIDC token minted" to "step that needs credentials" is longer than you think — environment wait timers, required reviewers, and queued runners all eat into the same hour. Re-run configure-aws-credentials with role-chaining: true at the start of any stage that's >30 minutes after the hub step.
Workflow fails immediately with Error: Unable to retrieve OIDC ID token: NotFound. The job is missing permissions: id-token: write. The block in Step 4's workflow grants it at the workflow level; if you scope permissions per-job, every job that calls configure-aws-credentials needs that line5.
Next steps
You now have one OIDC trust anchor and N spoke roles, gated by GitHub Environments and forward-compat with the June 18 rollout. Three natural extensions:
- Add a fourth spoke for a separate development account. Everything in Step 3 is
account_id-templated; add adevenvironment in GitHub, a:environment:developmentglob in the hub trust policy, and adeploy-developmentrole in the new account. No new hub-side resources. - Replace the inline
hub-permissions.jsonAssumeRole list with a tag-based condition. Instead of listing each spoke ARN explicitly, attach a tag likePurpose=GitHubDeployto every spoke role and write the hub policy as"Resource": "*"with"Condition": {"StringEquals": {"iam:ResourceTag/Purpose": "GitHubDeploy"}}. New spokes need only the tag. - Pair this with a fail-open guardrail on the deploy itself. If the spoke account is briefly unreachable, the deploy should fail loudly rather than wedge — the fail-open vs fail-closed middleware patterns generalize to CI guardrails as well.
The hub-and-spoke pattern scales linearly with accounts and stays auditable as it grows. The pieces most people get wrong — sts:TagSession, role-chaining: true, and the 1-hour cap — are exactly the pieces this tutorial pinned down with verified sources, so when the next engineer on your team asks "why is there a 3600 in our deploy workflow?" you have an answer that doesn't start with "I think…"
Footnotes
-
aws-actions/configure-aws-credentialsv6.1.3 release notes (2026-05-27); v6.0.0 release notes (2026-02-04) document the node24 runtime bump and the GitHub Actions Runner v2.327.1 minimum. https://github.com/aws-actions/configure-aws-credentials/releases ↩ ↩2 -
HashiCorp Terraform releases — 1.15.5 is the current stable per the authoritative release directory. https://releases.hashicorp.com/terraform/ ↩
-
AWS CLI v2 installation reference. https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html ↩
-
hashicorp/setup-terraformv4.0.1 release notes (2026-05-12). https://github.com/hashicorp/setup-terraform/releases/tag/v4.0.1 ↩ -
GitHub OpenID Connect reference — minting JWTs, the
id-token: writepermission, audience and subject claim formats. https://docs.github.com/en/actions/concepts/security/openid-connect ↩ ↩2 -
"Obtain the thumbprint for an OpenID Connect identity provider" — AWS IAM docs explicitly state the thumbprint is no longer required for the GitHub provider and is ignored if supplied. https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html ↩
-
"How to use trust policies with IAM roles" — AWS Security Blog on pinning the trust principal to the exact role ARN instead of the account root. https://aws.amazon.com/blogs/security/how-to-use-trust-policies-with-iam-roles/ ↩
-
aws-actions/configure-aws-credentialsaction manifest,role-chaininginput description: "Use existing credentials from the environment to assume a new role, rather than providing credentials as input." https://github.com/aws-actions/configure-aws-credentials/blob/main/action.yml ↩ -
Issue #391 on
aws-actions/configure-aws-credentials— the canonical write-up of "I forgot role-chaining: true and got AssumeRoleWithWebIdentity not authorized." https://github.com/aws-actions/configure-aws-credentials/issues/391 ↩ ↩2 -
Issue #1396 — the credentials-chaining README example does not work without either
sts:TagSessionon the spoke trust policy ORrole-skip-session-tagging: trueon the chaining step. https://github.com/aws-actions/configure-aws-credentials/issues/1396 ↩ -
GitHub Environments and protection rules — required reviewers, wait timers, branch restrictions. https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manage-environments ↩ ↩2
-
GitHub OIDC reference — the subject claim is
repo:ORG-NAME/REPO-NAME:environment:ENVIRONMENT-NAMEwhen a job references an environment, replacing the branchref:form. https://docs.github.com/actions/reference/openid-connect-reference ↩ -
AWS STS
AssumeRoleAPI reference — "When you use role chaining, your new credentials are limited to a maximum duration of one hour." https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html ↩ ↩2 -
"Immutable subject claims for GitHub Actions OIDC tokens" — GitHub Changelog, 2026-04-23. New repositories adopt the immutable format automatically on 2026-06-18; existing repositories must opt in. github.com only. https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/ ↩