TL;DR — Quick Fix
Set up secure cross-account access with external ID and least-privilege conditions:
# In Account B (trusting account) — create the role
aws iam create-role --role-name CrossAccountDeployRole \
--assume-role-policy-document file://trust-policy.json
# In Account A (trusted account) — assume the role
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/CrossAccountDeployRole \
--role-session-name deploy-session \
--external-id "UniqueSecret123"
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "UniqueSecret123"
},
"Bool": {
"aws:MultiFactorAuthPresent": "true"
}
}
}]
}
---
Architecture — Cross-Account Role Assumption Flow
---
Step 1 — Create the Cross-Account Role (Account B)
# Terraform — cross-account role in Account B
resource "aws_iam_role" "cross_account_deploy" {
name = "CrossAccountDeployRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::111111111111:root"
}
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"sts:ExternalId" = var.external_id
}
ArnLike = {
"aws:PrincipalArn" = "arn:aws:iam::111111111111:role/CI-*"
}
}
}]
})
max_session_duration = 3600
}
# Least-privilege permission policy
resource "aws_iam_role_policy" "deploy_permissions" {
name = "deploy-permissions"
role = aws_iam_role.cross_account_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
]
Resource = [
"arn:aws:s3:::deploy-artifacts-bucket",
"arn:aws:s3:::deploy-artifacts-bucket/*"
]
},
{
Effect = "Allow"
Action = [
"ecs:UpdateService",
"ecs:DescribeServices"
]
Resource = "arn:aws:ecs:us-east-1:222222222222:service/production/*"
}
]
})
}
---
Step 2 — Grant AssumeRole Permission (Account A)
# In Account A — allow CI role to assume the cross-account role
resource "aws_iam_policy" "allow_assume_deploy" {
name = "AllowAssumeCrossAccountDeploy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Resource = "arn:aws:iam::222222222222:role/CrossAccountDeployRole"
Condition = {
StringEquals = {
"aws:RequestedRegion" = ["us-east-1", "us-west-2"]
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "ci_assume" {
role = "CI-DeployRole"
policy_arn = aws_iam_policy.allow_assume_deploy.arn
}
---
Step 3 — ABAC (Attribute-Based Access Control)
Tag-based access eliminates the need to update policies per-resource:
# ABAC policy — access based on tags, not ARNs
resource "aws_iam_role_policy" "abac_policy" {
name = "abac-tag-based-access"
role = aws_iam_role.cross_account_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["ec2:StartInstances", "ec2:StopInstances"]
Resource = "*"
Condition = {
StringEquals = {
"aws:ResourceTag/Team" = "$${aws:PrincipalTag/Team}"
"aws:ResourceTag/Environment" = "production"
}
}
},
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "*"
Condition = {
StringEquals = {
"s3:ResourceTag/Project" = "$${aws:PrincipalTag/Project}"
}
}
}
]
})
}
---
Step 4 — Common Misconfigurations to Avoid
# Audit: Find roles with overly permissive trust policies
aws iam list-roles --query 'Roles[].RoleName' --output table
# Check who has assumed a role recently
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
--start-time "2026-08-01" \
--query 'Events[].{Time:EventTime,User:Username}'
| Misconfiguration | Risk | Fix |
|---|---|---|
| <code class="inline-code">Principal: "*"</code> | Anyone can assume | Specify exact account/role ARN |
| No external ID | Confused deputy attack | Add <code class="inline-code">sts:ExternalId</code> condition |
| No session duration limit | Long-lived credentials | Set <code class="inline-code">max_session_duration: 3600</code> |
| Wildcard actions (<code class="inline-code">*</code>) | Over-privileged | List specific actions needed |
| No IP/VPC condition | Access from anywhere | Add <code class="inline-code">aws:SourceIp</code> condition |
---
Step 5 — CloudTrail Audit Logging
# Ensure cross-account AssumeRole events are logged
resource "aws_cloudtrail" "org_trail" {
name = "org-audit-trail"
s3_bucket_name = aws_s3_bucket.trail_bucket.id
is_multi_region_trail = true
is_organization_trail = true
event_selector {
read_write_type = "All"
include_management_events = true
}
}
# CloudWatch alarm for unusual cross-account access
resource "aws_cloudwatch_metric_alarm" "unusual_assume_role" {
alarm_name = "unusual-cross-account-access"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "AssumeRoleCount"
namespace = "CustomMetrics/IAM"
period = 300
statistic = "Sum"
threshold = 50
alarm_description = "Unusual number of cross-account AssumeRole calls"
alarm_actions = [aws_sns_topic.security_alerts.arn]
}
---
Frequently Asked Questions
When should I use external ID vs MFA condition?
Use external ID for service-to-service (third-party SaaS accessing your account). Use MFA condition for human access (engineers assuming roles manually). For CI/CD pipelines, use neither — instead restrict the Principal to the exact CI role ARN.
Can I use cross-account roles with AWS Organizations?
Yes, and it is preferred. With Organizations, you can use aws:PrincipalOrgID as a condition to only allow roles from your organization. This is more secure than specifying individual account IDs: "aws:PrincipalOrgID": "o-xxxxxxxxxxxx".
How do I rotate external IDs without downtime?
Add both old and new external IDs to the trust policy temporarily: "sts:ExternalId": ["old-id", "new-id"]. Update the calling service to use the new ID, then remove the old one from the trust policy.
What is the maximum session duration for assumed roles?
Default is 1 hour, configurable up to 12 hours via max_session_duration on the role. For CI/CD, keep it short (1 hour). For humans doing manual work, 4-8 hours is reasonable.
How do I debug AccessDenied when assuming roles?
Check these in order: (1) The trust policy in Account B allows Account A principal, (2) Account A entity has sts:AssumeRole permission for the target role ARN, (3) All conditions match (ExternalId, MFA, IP), (4) No SCPs (Service Control Policies) are blocking the action.
---