Skip to main content
Cloud & AWS·6 min read

Cross-Account AWS IAM Role Configuration Without Security Holes

Configure cross-account AWS IAM roles securely. Learn trust policies, assume-role patterns, external ID for third-party access, ABAC, condition keys, and audit logging with CloudTrail.

DT

DevOps Engineer & Technical Writer

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

CROSS-ACCOUNT IAM ROLE ASSUMPTION — SECURE FLOW ACCOUNT A (111111111111) — Trusted IAM User/Role sts:AssumeRole perm Permission Policy: Allow sts:AssumeRole on Account B role ARN STS Call with: RoleARN + ExternalId + SessionName ACCOUNT B (222222222222) — Trusting Trust Policy (Who can assume) Principal: Account A Condition: ExternalId + MFA Permission Policy (What it can do) s3:GetObject, ec2:Describe* (least privilege) CloudTrail Logging All AssumeRole events recorded AssumeRole Temp credentials (1hr)

---

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}'

MisconfigurationRiskFix
<code class="inline-code">Principal: &quot;*&quot;</code>Anyone can assumeSpecify exact account/role ARN
No external IDConfused deputy attackAdd <code class="inline-code">sts:ExternalId</code> condition
No session duration limitLong-lived credentialsSet <code class="inline-code">max_session_duration: 3600</code>
Wildcard actions (<code class="inline-code">*</code>)Over-privilegedList specific actions needed
No IP/VPC conditionAccess from anywhereAdd <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.

---