A multi-account setup requires cross-account access. CI/CD pipelines need to deploy to multiple accounts. Identity management needs to create roles in member accounts. DNS management needs to access zones in the DNS account.
We use three mechanisms to make this work safely: deployment roles, role chaining, and external ID protection.
The Role Assumption Model
Cross-account access in AWS works through role assumption:
- Principal (user or role) in Account A wants to access Account B
- Account B has an IAM role with a trust policy allowing Account A
- Principal calls
sts:AssumeRoleto get temporary credentials - Temporary credentials allow actions in Account B
The key is the trust policy - it controls who can assume the role.
Deployment Roles
We create specific deployment roles for each service/account combination:
IdentityManagementDeploymentRole
Lives in the management account. Used by CI/CD to manage Identity Center:
resource "aws_iam_role" "identity_management_deployment" {
name = "IdentityManagementDeploymentRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
# GitHub Actions can assume this role
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::123456789012:role/github-actions"
}
Action = "sts:AssumeRole"
},
# SSO users with the deployment permission set can also assume it
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::123456789012:root"
}
Action = "sts:AssumeRole"
Condition = {
StringLike = {
"aws:userid": "AROA*:IdentityMgmtDeploymentAccess/*"
}
}
}
]
})
}The trust policy allows two principals:
- The GitHub Actions OIDC role
- SSO users who have the
IdentityMgmtDeploymentAccesspermission set
Scoped Permissions
Instead of AdministratorAccess, we scope permissions to what the deployment actually needs:
resource "aws_iam_role_policy" "identity_management_deployment" {
role = aws_iam_role.identity_management_deployment.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"sso:*",
"sso-directory:*",
"identitystore:*",
"organizations:ListAccounts",
"organizations:DescribeAccount",
"organizations:DescribeOrganization"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"iam:GetRole",
"iam:CreateRole",
"iam:DeleteRole",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy"
]
Resource = "arn:aws:iam::*:role/*Deployment*"
},
{
Effect = "Allow"
Action = ["s3:*"]
Resource = [
"arn:aws:s3:::myorg-terraform-state-*",
"arn:aws:s3:::myorg-terraform-state-*/*"
]
}
]
})
}This role can manage Identity Center resources and Terraform state, but can't do arbitrary IAM changes or access unrelated services.
Cross-Account Deployment Roles
For member accounts, we create deployment roles that trust the management account:
DNSDeploymentRole
Lives in the DNS account:
resource "aws_iam_role" "dns_deployment" {
provider = aws.dns_management
name = "DNSDeploymentRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::123456789012:role/github-actions"
}
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"sts:ExternalId" = var.dns_deployment_external_id
}
}
}
]
})
}
resource "aws_iam_role_policy" "dns_deployment" {
provider = aws.dns_management
role = aws_iam_role.dns_deployment.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "route53:*"
Resource = [
"arn:aws:route53:::hostedzone/Z1234567890ABC",
"arn:aws:route53:::hostedzone/Z0987654321XYZ"
]
},
{
Effect = "Allow"
Action = [
"route53:ListHostedZones",
"route53:GetChange"
]
Resource = "*"
}
]
})
}Key elements:
External ID requirement: The trust policy requires a role-specific external ID (sts:ExternalId = var.dns_deployment_external_id). This prevents confused deputy attacks.
Scoped zone access: The role can only modify specific hosted zones, not create new ones or access other zones.
Multi-provider deployment: Using provider = aws.dns_management deploys this role in the DNS account, not the management account.
External IDs Explained
External IDs protect against confused deputy attacks. Here's the scenario without external IDs:
- Attacker creates AWS account
- Attacker creates role trusting your GitHub Actions role ARN
- Attacker tricks your CI/CD into assuming their role
- Your credentials now work in attacker's account
With external IDs:
- Your role requires a specific external ID value
- Attacker doesn't know this value
- Attacker's trust policy can't require the same external ID
- Attack fails
External ID guidance:
| Rule | Why |
|---|---|
| Use one external ID per trust relationship | Prevents accidental role crossover between systems |
| Keep values non-trivial and managed like config | Reduces guessing and copy/paste mistakes |
| Treat as shared control data, not a secret | Both caller and target account must know it |
Role Chaining
Role chaining is when a role assumes another role. The CI/CD flow:
GitHub Actions → OIDC Role → Deployment Role → (actions)The GitHub Actions workflow:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions
aws-region: eu-west-2
- name: Assume DNS deployment role
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/DNSDeploymentRole
role-external-id: ${{ secrets.DNS_DEPLOYMENT_EXTERNAL_ID }}
role-chaining: true
aws-region: eu-west-2
- name: Deploy DNS changes
run: |
cd tf
tofu init
tofu apply -auto-approverole-chaining: true: Tells the action to use existing credentials to assume the new role, rather than using OIDC again.
OrganizationAccountAccessRole
When you create an account through Organizations, AWS automatically creates OrganizationAccountAccessRole in the new account. This role:
- Trusts the management account
- Has AdministratorAccess
- Allows initial management of the new account
We use this for initial setup, then create scoped deployment roles:
The identity management Terraform uses the org role's provider to create scoped deployment roles in each member account. Once those scoped roles exist, CI/CD switches to using them and the org role becomes a break-glass path only.
SSO to Deployment Role Pattern
SSO users can also assume deployment roles, useful for local development:
# Permission set grants ability to assume the role
resource "aws_ssoadmin_permission_set" "dns_deployment_access" {
name = "DNSDeploymentAccess"
description = "Permission to assume DNSDeploymentRole"
instance_arn = local.identity_center_instance_arn
session_duration = "PT2H"
}
resource "aws_ssoadmin_permission_set_inline_policy" "dns_deployment_access" {
instance_arn = local.identity_center_instance_arn
permission_set_arn = aws_ssoadmin_permission_set.dns_deployment_access.arn
inline_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["sts:AssumeRole", "sts:TagSession"]
Resource = "arn:aws:iam::111111111111:role/DNSDeploymentRole"
Condition = {
StringEquals = {
"sts:ExternalId" = var.dns_deployment_external_id
}
}
}
]
})
}The SSO user's experience:
- Log in to Identity Center
- Assume the DNSDeploymentAccess permission set
- Run
aws sts assume-rolewith the external ID - Use the deployment role credentials
Field note: we keep separate external IDs for DNS and website deployment paths. Reusing one value across multiple roles made policy reviews noisier and easier to misread.
cert-manager Service Account
For Kubernetes cert-manager to manage DNS challenges, we create a dedicated IAM user:
This pattern is for non-EKS clusters where IRSA is not available. In EKS, prefer IAM Roles for Service Accounts (IRSA) so pods assume roles directly via the cluster OIDC provider instead of using long-lived user credentials.
Quick decision rule:
- Non-EKS Kubernetes: IAM user plus scoped assume-role can be a pragmatic bridge.
- EKS: Use IRSA with a service account annotation and an IAM role trust policy for the cluster OIDC provider.
If you run the non-EKS bridge pattern, keep the IAM user tightly scoped and rotate its access key on a short, scheduled cadence.
resource "aws_iam_user" "certmanager" {
provider = aws.dns_management
name = "certmanager-dns-challenge"
path = "/service-accounts/"
}
resource "aws_iam_role" "certmanager_dns" {
provider = aws.dns_management
name = "CertManagerDNSRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = aws_iam_user.certmanager.arn
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy" "certmanager_dns" {
provider = aws.dns_management
role = aws_iam_role.certmanager_dns.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "route53:GetChange"
Resource = "arn:aws:route53:::change/*"
},
{
Effect = "Allow"
Action = [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets"
]
Resource = "arn:aws:route53:::hostedzone/Z1234567890ABC"
Condition = {
"ForAllValues:StringEquals" = {
"route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"]
}
"ForAllValues:StringLike" = {
"route53:ChangeResourceRecordSetsRecordNames" = ["_acme-challenge.*"]
}
}
}
]
})
}This is as scoped as possible:
- Can only modify TXT records
- Can only modify
_acme-challenge.*subdomains - Can only access one specific hosted zone
Summary
Cross-account access should be:
- Explicit: Every cross-account relationship requires a trust policy
- Scoped: Roles have minimum necessary permissions
- Protected: External IDs prevent confused deputy attacks
- Auditable: CloudTrail logs every
AssumeRolecall
The pattern is consistent: create a role in the target account, scope it narrowly, protect it with external IDs, and grant specific principals the ability to assume it.
This is also where many AWS estates accumulate hidden risk. Trust policies, external IDs, and role chains tend to grow organically and drift over time. Periodic trust-graph reviews and policy tightening reduce exposure without slowing delivery.