Multi-Account AWS Strategy
Federate Vouch into dev, staging, and production AWS accounts
Multi-account AWS layouts use role chaining: the Vouch OIDC provider lives only in the management account, developers federate into a single management-account “hub” role, and the hub assumes “spoke” roles in member accounts using sts:AssumeRole. This page picks up where AWS / Step 3 / Pattern C leaves off.
We don’t recommend deploying separate OIDC providers in every account – it multiplies maintenance, and AWS Organizations exists precisely so you don’t have to. One hub plus per-account spokes covers the same use cases with less surface area.
Architecture
Vouch (OIDC) ──▶ Management Account ──▶ Member Account
VouchAccess (hub) VouchAccess (spoke)
AssumeRoleWithWebIdentity AssumeRole + SourceIdentity
- The Vouch OIDC provider lives in the management account only.
- The hub role uses Pattern C – its identity policy is
sts:AssumeRoleonly. - Each spoke role trusts the hub through a plain AWS-principal trust (no OIDC).
- The developer’s verified email propagates through the chain as
SourceIdentity: set toalice@example.comatAssumeRoleWithWebIdentity, carried forward through eachAssumeRole, recorded in CloudTrail in every member account. - All session tags are transitive, so conditions like
aws:PrincipalTag/vouch:Domainandaws:PrincipalTag/vouch:Emailwork in spoke trust policies just as they do in the hub.
Every developer uses the same vouch login session. The AWS profile they select determines which spoke role – and therefore which account – they assume.
Step 1 – Deploy the hub role
Admin task
Deploy the hub role in your management account using Pattern C from the AWS guide. Two specifics for the hub-and-spoke topology:
- The trust policy’s
subcondition uses your email domain (e.g.*@example.com). - The identity policy’s
Resourcemust match the spoke role ARNs you’ll deploy in Step 2:arn:aws:iam::*:role/vouch/VouchAccess.
Replace the aws:PrincipalOrgId placeholder with your AWS Organization ID, and record the hub role ARN (e.g. arn:aws:iam::999999999999:role/vouch/VouchAccess) – every spoke role’s trust policy references it.
Step 2 – Deploy spoke roles in member accounts
Admin task
Each member account needs a VouchAccess role that trusts the hub role and grants the actual permissions developers need in that account. The spoke role’s trust policy is a plain AWS-principal trust (no OIDC provider, no JWT condition) because the chained AssumeRole call comes from a regular IAM role, not from a federated identity. The aws:SourceIdentity condition ensures only requests originating from an authenticated Vouch user with a matching email domain can assume the role.
Pick one of the deployment options below. Both produce the same result.
Option A – CloudFormation StackSets
StackSets deploy a single template across every account in your AWS Organization (or a chosen OU).
AWSTemplateFormatVersion: "2010-09-09"
Description: "Vouch spoke role (deployed via StackSet)"
Parameters:
ManagementAccountId:
Type: String
Description: "Account ID of the AWS Organization management account"
EmailDomain:
Type: String
Description: Your Google Workspace domain (e.g. example.com)
ManagedPolicyArn:
Type: String
Default: "arn:aws:iam::aws:policy/ReadOnlyAccess"
Description: "Permissions policy to attach to the spoke role"
Resources:
VouchSpokeRole:
Type: AWS::IAM::Role
Properties:
RoleName: VouchAccess
Path: /vouch/
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Sub "arn:${AWS::Partition}:iam::${ManagementAccountId}:role/vouch/VouchAccess"
Action:
- "sts:AssumeRole"
- "sts:SetSourceIdentity"
- "sts:TagSession"
Condition:
StringLike:
"aws:SourceIdentity": !Sub "*@${EmailDomain}"
ManagedPolicyArns:
- !Ref ManagedPolicyArn
Outputs:
RoleArn:
Value: !GetAtt VouchSpokeRole.Arn
Deploy from the management account:
aws cloudformation create-stack-set \
--stack-set-name vouch-spokes \
--template-body file://vouch-spoke.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--permission-model SERVICE_MANAGED \
--auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false
aws cloudformation create-stack-instances \
--stack-set-name vouch-spokes \
--deployment-targets OrganizationalUnitIds=ou-xxxx-xxxxxxxx \
--regions us-east-1 \
--parameter-overrides \
ParameterKey=ManagementAccountId,ParameterValue=999999999999 \
ParameterKey=EmailDomain,ParameterValue=example.com
With --auto-deployment Enabled, new accounts added to the OU automatically receive the spoke role.
Per-account permissions
Override ManagedPolicyArn per account to scope permissions:
# Development: PowerUserAccess
aws cloudformation create-stack-instances \
--stack-set-name vouch-spokes \
--accounts 111111111111 \
--regions us-east-1 \
--parameter-overrides \
ParameterKey=ManagementAccountId,ParameterValue=999999999999 \
ParameterKey=EmailDomain,ParameterValue=example.com \
ParameterKey=ManagedPolicyArn,ParameterValue=arn:aws:iam::aws:policy/PowerUserAccess
# Production: ReadOnlyAccess
aws cloudformation create-stack-instances \
--stack-set-name vouch-spokes \
--accounts 222222222222 \
--regions us-east-1 \
--parameter-overrides \
ParameterKey=ManagementAccountId,ParameterValue=999999999999 \
ParameterKey=EmailDomain,ParameterValue=example.com \
ParameterKey=ManagedPolicyArn,ParameterValue=arn:aws:iam::aws:policy/ReadOnlyAccess
Option B – Terraform
Create a reusable module for the spoke role:
# modules/vouch-spoke/main.tf
variable "management_account_id" {
type = string
description = "AWS Organization management account ID"
}
variable "email_domain" {
type = string
description = "Email domain to allow via SourceIdentity"
}
variable "policy_arns" {
type = list(string)
default = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
}
data "aws_partition" "current" {}
resource "aws_iam_role" "vouch_spoke" {
name = "VouchAccess"
path = "/vouch/"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = "arn:${data.aws_partition.current.partition}:iam::${var.management_account_id}:role/vouch/VouchAccess"
}
Action = [
"sts:AssumeRole",
"sts:SetSourceIdentity",
"sts:TagSession",
]
Condition = {
StringLike = {
"aws:SourceIdentity" = "*@${var.email_domain}"
}
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "vouch_spoke" {
count = length(var.policy_arns)
role = aws_iam_role.vouch_spoke.name
policy_arn = var.policy_arns[count.index]
}
output "role_arn" {
value = aws_iam_role.vouch_spoke.arn
}
Per-account usage:
# environments/dev/main.tf
module "vouch" {
source = "../../modules/vouch-spoke"
management_account_id = "999999999999"
email_domain = "example.com"
policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
}
# environments/prod/main.tf
module "vouch" {
source = "../../modules/vouch-spoke"
management_account_id = "999999999999"
email_domain = "example.com"
policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
}
You are done with role deployment when...
- The hub role exists in the management account with an
sts:AssumeRole-only identity policy. - Each member account has a
/vouch/VouchAccessrole whose trust policy lists the hub role as principal. - From an authenticated Vouch session you can assume the hub and chain into a spoke; CloudTrail in the spoke account records the developer's email as
SourceIdentity.
Step 3 – Configure developer profiles
Developer task
Each developer configures a named AWS profile per account. The role ARN points to the spoke role (/vouch/VouchAccess) in the target account; the Vouch CLI handles the chain through the hub:
# Development account
vouch setup aws \
--role arn:aws:iam::111111111111:role/vouch/VouchAccess \
--profile vouch-dev
# Staging account
vouch setup aws \
--role arn:aws:iam::333333333333:role/vouch/VouchAccess \
--profile vouch-staging
# Production account
vouch setup aws \
--role arn:aws:iam::222222222222:role/vouch/VouchAccess \
--profile vouch-prod
This produces the following ~/.aws/config:
[profile vouch-dev]
credential_process = vouch credential aws --role arn:aws:iam::111111111111:role/vouch/VouchAccess
[profile vouch-staging]
credential_process = vouch credential aws --role arn:aws:iam::333333333333:role/vouch/VouchAccess
[profile vouch-prod]
credential_process = vouch credential aws --role arn:aws:iam::222222222222:role/vouch/VouchAccess
Use profiles per command:
# Deploy to dev
cdk deploy --profile vouch-dev
# Check production
aws s3 ls --profile vouch-prod
Or set a default:
export AWS_PROFILE=vouch-dev
Auto-discovery with SSO
If your organization uses AWS IAM Identity Center, developers can skip the manual per-account configuration entirely:
# Authenticate with IAM Identity Center
vouch aws login
# See which accounts are available
vouch aws accounts
# See which roles are available in a specific account
vouch aws roles --account 111111111111
# Auto-discover all accounts and roles, configure all profiles at once
vouch setup aws --discover --prefix vouch --region us-east-1
The --discover flag queries IAM Identity Center for every account and role the developer can access, and writes a credential_process profile for each one into ~/.aws/config. This is especially useful in organizations with many accounts where maintaining per-account setup commands is impractical.
Restricting federation with SCPs
Use Service Control Policies to ensure that only the Vouch OIDC provider can federate into your accounts. This prevents anyone from registering a rogue OIDC provider:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyNonVouchOIDCProviders",
"Effect": "Deny",
"Action": [
"iam:CreateOpenIDConnectProvider",
"iam:DeleteOpenIDConnectProvider"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalOrgID": "${aws:PrincipalOrgID}"
}
}
}
]
}
Note: Adjust this SCP to allow your management account or deployment pipeline to manage OIDC providers. The goal is to prevent individual developers from creating unauthorized OIDC providers in member accounts.
Protecting Vouch roles from accidental deletion
A common convention is to prefix critical roles with DO-NOT-DELETE-*, but that is a social signal, not a control. Tired engineers ignore it; automation never reads it. Use technical guardrails as the real protection, and use names, paths, and tags only as the addressing scheme those guardrails attach to.
Use an IAM path, not a name prefix
Place Vouch roles under a dedicated IAM path such as /vouch/. Paths are first-class in the role ARN, can be wildcarded in policy Resource fields, and don’t pollute the role’s display name:
arn:aws:iam::123456789012:role/vouch/VouchAccess
In CloudFormation, add a single line to the role:
VouchRole:
Type: AWS::IAM::Role
Properties:
RoleName: VouchAccess
Path: /vouch/
In Terraform:
resource "aws_iam_role" "vouch" {
name = var.role_name
path = "/vouch/"
# ...
}
Tag for ABAC and inventory
Tag every Vouch-managed resource so an SCP or audit query can find it even if someone forgets the path:
Tags:
- Key: ManagedBy
Value: Vouch
- Key: Purpose
Value: OIDCFederation
Deny deletion with an SCP
This is the actual control. Deny destructive IAM actions against anything in the /vouch/ path and against the Vouch OIDC provider, with an exception for your deployment principal so legitimate updates can still happen:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProtectVouchRoles",
"Effect": "Deny",
"Action": [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:UpdateAssumeRolePolicy",
"iam:DeleteOpenIDConnectProvider"
],
"Resource": [
"arn:aws:iam::*:role/vouch/*",
"arn:aws:iam::*:oidc-provider/us.vouch.sh"
],
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/VouchDeploymentRole"
}
}
}
]
}
Replace VouchDeploymentRole with whatever principal your CloudFormation StackSet, Terraform pipeline, or platform team uses.
IaC-level protections
Belt-and-suspenders settings in your stack definitions catch the cases where someone bypasses the SCP exception and runs terraform destroy or deletes a CloudFormation stack:
- CloudFormation: set
DeletionPolicy: RetainandUpdateReplacePolicy: Retainon the role and OIDC provider resources. - Terraform: add
lifecycle { prevent_destroy = true }to each resource. - StackSets: enable termination protection on the stack set itself.
Detective backstop
Even with all of the above, alert on the action so you find out fast if something slips through. An EventBridge rule on DeleteRole and DeleteOpenIDConnectProvider events filtered to the /vouch/ path, targeting an SNS topic, gives you a notification within seconds.
Role design patterns
By environment
The default spoke role is /vouch/VouchAccess, scoped per-account by the policy you attach. For higher-privilege production access, deploy a second spoke role with a tighter aws:SourceIdentity allowlist:
| Account | Spoke role | Policy | Who can chain in |
|---|---|---|---|
| Development | vouch/VouchAccess | PowerUserAccess | Anyone in *@example.com |
| Staging | vouch/VouchAccess | PowerUserAccess | Anyone in *@example.com |
| Production | vouch/VouchAccess | ReadOnlyAccess | Anyone in *@example.com |
| Production | vouch/VouchDeploy | Custom deploy policy | Specific emails only |
Restricting production access to specific people
Tighten the aws:SourceIdentity condition on the production deployer’s spoke role:
"Condition": {
"StringEquals": {
"aws:SourceIdentity": [
"alice@example.com",
"bob@example.com"
]
}
}
Only Alice and Bob can chain into vouch/VouchDeploy. Everyone else still gets the read-only vouch/VouchAccess spoke.
Troubleshooting
Credentials for the wrong account
If aws sts get-caller-identity shows an unexpected account, verify you are using the correct profile:
aws sts get-caller-identity --profile vouch-dev
Check ~/.aws/config for the correct role ARN in each profile.