IAM Cross-Account Roles: Secure Multi-Account Access on AWS

Bits Lovers
Written by Bits Lovers on
IAM Cross-Account Roles: Secure Multi-Account Access on AWS

The standard AWS multi-account setup has a tools account for CI/CD, separate accounts for dev/staging/prod, a security audit account, and maybe a shared services account for internal tooling. Getting code from the tools account into a production account without storing static credentials anywhere requires cross-account role assumption. It’s the mechanism AWS built specifically for this — temporary credentials, full audit trail, no access keys to rotate.

Cross-account role assumption works through two IAM policies on two different accounts. The target account creates a role with a trust policy that names the source account’s principal as trusted. The source account gives its principal permission to call sts:AssumeRole targeting that role. Both sides must allow the action. This is different from same-account access, where a resource-based policy on one side is sometimes sufficient. Cross-account requires both.

The Basic Mechanism

Say Account A (your CI/CD account) needs to deploy infrastructure into Account B (production). Here’s the complete setup.

In Account B (the target) — create the deployable role:

# Trust policy: allow Account A's CI role to assume this role
cat > /tmp/trust-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "AWS": "arn:aws:iam::111111111111:role/CIPipelineRole"
    },
    "Action": "sts:AssumeRole"
  }]
}
EOF

# Create the role in Account B
aws iam create-role \
  --role-name ProductionDeployRole \
  --assume-role-policy-document file:///tmp/trust-policy.json

# Attach the permissions this role needs (scoped to what deployment requires)
aws iam attach-role-policy \
  --role-name ProductionDeployRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonECS_FullAccess

In Account A (the source) — give the CI role permission to assume:

# Attach a policy to CIPipelineRole allowing it to assume the prod role
aws iam put-role-policy \
  --role-name CIPipelineRole \
  --policy-name AssumeProductionRole \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": "arn:aws:iam::222222222222:role/ProductionDeployRole"
    }]
  }'

Assuming the role from Account A:

# Assume the role and capture the credentials
CREDS=$(aws sts assume-role \
  --role-arn arn:aws:iam::222222222222:role/ProductionDeployRole \
  --role-session-name ci-pipeline-$(date +%s) \
  --output json)

# Export the temporary credentials
export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.Credentials.AccessKeyId')
export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.Credentials.SecretAccessKey')
export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.Credentials.SessionToken')

# Now commands run as ProductionDeployRole in Account B
aws ecs update-service --cluster production --service api --force-new-deployment

The --role-session-name appears in CloudTrail under userIdentity.sessionContext.sessionIssuer. Use a meaningful value — the pipeline run ID, a timestamp, the triggering user — so you can correlate CloudTrail events back to specific pipeline runs. Generic names like ci-session make audit logs useless.

Trust Policy Principal Scoping

The Principal in the trust policy controls who can assume the role. Three levels of trust exist.

Specific role (tightest): Only the exact role ARN can assume.

"Principal": {
  "AWS": "arn:aws:iam::111111111111:role/CIPipelineRole"
}

Account root (looser): Any principal in Account A with sts:AssumeRole permission can assume the role. The account itself doesn’t assume roles — individual IAM identities do, but they need sts:AssumeRole permission in their own account’s policies.

"Principal": {
  "AWS": "arn:aws:iam::111111111111:root"
}

Specific IAM user (avoid for automation): Works, but users have long-lived credentials. Roles are always better for programmatic access.

"Principal": {
  "AWS": "arn:aws:iam::111111111111:user/alice"
}

Use the specific role ARN for CI/CD pipelines. Use account root when you want any identity in the source account to be able to assume, with access controlled entirely by Account A’s own IAM policies. Most multi-account setups use specific role ARNs in trust policies — it’s more auditable and reduces the blast radius if any identity in Account A is compromised.

Organization-Wide Trust with aws:PrincipalOrgID

If you manage multiple accounts under AWS Organizations and want to allow any of your organizational accounts to assume a role — without listing every account ARN individually — use the aws:PrincipalOrgID condition:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "AWS": "*"
    },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": {
        "aws:PrincipalOrgID": "o-xxxxxxxxxxxx"
      }
    }
  }]
}

The Principal: "*" allows any principal, but the condition restricts it to principals from your organization’s ID. Accounts that join your organization can immediately assume the role (as long as their own policies allow it). Accounts that leave lose access automatically. This pattern is common for shared infrastructure roles — a centralized logging role, a security audit role, a shared services endpoint — where every account in the org needs access.

Find your organization ID:

aws organizations describe-organization \
  --query 'Organization.Id' --output text

This pattern is the right foundation for the multi-account structures covered in the AWS Organizations and Control Tower guide.

External ID: Protecting Against the Confused Deputy Problem

When a third-party service (a SaaS monitoring tool, a security scanner, an audit platform) needs to access your AWS account, they ask you to create a role and trust their AWS account. The problem: if that third party is malicious or compromised, they could use your role to access any customer that trusts them — the confused deputy attack.

The External ID condition prevents this. The third party gives each customer a unique identifier. The customer puts it in the trust policy condition. When the third party assumes the role, they must pass the matching External ID.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "AWS": "arn:aws:iam::999999999999:root"
    },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": {
        "sts:ExternalId": "your-unique-customer-id-12345"
      }
    }
  }]
}

The third party passes the External ID when assuming:

aws sts assume-role \
  --role-arn arn:aws:iam::YOUR_ACCOUNT:role/AuditRole \
  --role-session-name third-party-audit \
  --external-id your-unique-customer-id-12345

Without the External ID, the third party could trick your account into accepting requests from another customer’s context — they pass your role ARN to their system, and if there’s no External ID, the assumption succeeds regardless of who triggered it. With External ID, only requests that include your specific ID are accepted.

Always require an External ID when creating trust policies for third-party SaaS tools.

Session Duration and Token Expiry

sts:AssumeRole produces credentials valid for the role’s MaxSessionDuration. The default is one hour. For CI/CD pipelines that run longer than that, you need to increase it:

# Extend max session duration to 4 hours
aws iam update-role \
  --role-name ProductionDeployRole \
  --max-session-duration 14400  # seconds

# Request a longer session when assuming
aws sts assume-role \
  --role-arn arn:aws:iam::222222222222:role/ProductionDeployRole \
  --role-session-name ci-deploy \
  --duration-seconds 7200  # 2 hours

You can request any duration up to the role’s MaxSessionDuration. If you ask for longer than the role allows, the call fails. The error is ValidationError: The requested DurationSeconds exceeds the MaxSessionDuration set for this role — not the most intuitive message, but it’s explicit.

Terraform plans on large infrastructure can run for 90+ minutes. Deployments with dependency resolution, CloudFormation stacks, and database migrations can exceed two hours. Set MaxSessionDuration to at least the longest expected pipeline run plus some buffer. The alternative — credentials expiring mid-deployment — leaves infrastructure in a partially applied state.

Multi-Account CI/CD Pattern

A typical Terraform deployment pipeline across multiple accounts:

#!/bin/bash
set -euo pipefail

TOOLS_ACCOUNT_ROLE="arn:aws:iam::111111111111:role/CIPipelineRole"
TARGET_ACCOUNTS=(
  "arn:aws:iam::222222222222:role/TerraformDeployRole"
  "arn:aws:iam::333333333333:role/TerraformDeployRole"
  "arn:aws:iam::444444444444:role/TerraformDeployRole"
)

for role_arn in "${TARGET_ACCOUNTS[@]}"; do
  account_id=$(echo $role_arn | cut -d':' -f5)
  echo "Deploying to account $account_id..."

  # Assume the target account role
  CREDS=$(aws sts assume-role \
    --role-arn "$role_arn" \
    --role-session-name "ci-$(git rev-parse --short HEAD)" \
    --duration-seconds 3600 \
    --output json)

  export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.Credentials.AccessKeyId')
  export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.Credentials.SecretAccessKey')
  export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.Credentials.SessionToken')

  # Run Terraform in this account's context
  cd "accounts/$account_id"
  terraform init
  terraform apply -auto-approve

  # Clean up env vars before next iteration
  unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
  cd -
done

Each iteration assumes a fresh role in the target account, runs Terraform, then unsets the credentials before moving to the next account. The session name embeds the git commit SHA — every CloudTrail event in every account traces back to the exact code version that triggered it.

Chained Role Assumption

You can assume a role, then from that role assume another. This is role chaining, and it has limits.

The session duration in a chain is capped at one hour, regardless of what either role’s MaxSessionDuration allows. The chained session cannot exceed the original session’s remaining time. For long-running pipelines, chaining is the wrong pattern — assume the target role directly from the source principal rather than through intermediate roles.

Chaining is useful for audit scenarios: a security analyst assumes an audit role in the tools account, then from that role assumes a read-only role in each member account. The chain is: analyst → audit role → member account role. CloudTrail in each member account shows the full chain in the userIdentity field, making it easy to trace which analyst triggered what action.

Locking Down with Permission Boundaries

Cross-account roles are powerful and should be tightly scoped. Two mechanisms help.

First, scope the trust policy to a specific role ARN rather than account root whenever possible. This limits which principals in the source account can trigger assumption.

Second, apply a permission boundary to the target role. Even if someone in Account B modifies the role’s policies to add broader permissions, the boundary caps the effective access. The IAM permission boundaries guide covers the full setup for boundary-enforced delegation.

Third, use the iam:PassedToService condition to restrict which services can receive the role via iam:PassRole. If a role is specifically for ECS deployments, the trust policy should only allow ECS to use it — not any principal who can pass roles.

Auditing Cross-Account Access

CloudTrail in the target account captures AssumeRole events. Look for:

# Find all cross-account AssumeRole events in the last 24 hours
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time $(date -d '24 hours ago' --iso-8601=seconds) \
  --query 'Events[?contains(CloudTrailEvent, `sts:AssumeRole`) == `true`]' | \
  python3 -c "
import json,sys
events = json.load(sys.stdin)
for e in events:
    ct = json.loads(e['CloudTrailEvent'])
    principal = ct.get('userIdentity', {}).get('arn', 'unknown')
    session = ct.get('requestParameters', {}).get('roleSessionName', 'unknown')
    role = ct.get('requestParameters', {}).get('roleArn', 'unknown')
    print(f'{e[\"EventTime\"]}: {principal} → {role} (session: {session})')
"

Each successful assumption creates an event showing which external principal requested it, with what session name, and at what time. This is the cross-account access audit trail — CloudTrail in the source account shows the same event from the requesting principal’s perspective.

Cross-account roles are the right way to handle multi-account AWS access. No static credentials, automatic expiry, and full audit trail. The pattern connects directly to everything covered in the AWS IAM roles and policies guide — the trust mechanics are the same, just spanning account boundaries instead of staying within one.

Bits Lovers

Bits Lovers

Professional writer and blogger. Focus on Cloud Computing.

Comments

comments powered by Disqus