← Back to Blog

Secure Cross-Account Access Patterns at Scale in AWS

6 min read

Secure Cross-Account Access Patterns at Scale in AWS

Most AWS organizations manage dozens to hundreds of accounts. Every cross-account interaction, whether a security scanner reading CloudTrail logs, a deployment pipeline pushing to production, or a monitoring tool collecting metrics, requires an IAM trust relationship. Getting these patterns wrong creates attack paths that span your entire organization. This guide covers the principal cross-account access patterns, when to use each, and how to audit them at scale.

Hub-and-Spoke IAM Role Architecture

Central Security Account Pattern

The hub-and-spoke model designates one account (typically a security or tooling account) as the hub. The hub account contains IAM principals that assume roles in spoke accounts. Each spoke account has a standardized role with a trust policy pointing back to the hub.

import boto3
import json

class CrossAccountRoleManager:
    def __init__(self, hub_account_id, org_id):
        self.iam = boto3.client('iam')
        self.sts = boto3.client('sts')
        self.hub_account_id = hub_account_id
        self.org_id = org_id

    def create_spoke_role(self, role_name, hub_role_arn, external_id=None):
        """Create a cross-account role in a spoke account with tight trust."""
        trust_policy = {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "AWS": hub_role_arn
                    },
                    "Action": "sts:AssumeRole",
                    "Condition": {
                        "StringEquals": {
                            "aws:PrincipalOrgID": self.org_id
                        }
                    }
                }
            ]
        }

        # Add external ID requirement for third-party access
        if external_id:
            trust_policy["Statement"][0]["Condition"]["StringEquals"]["sts:ExternalId"] = external_id

        role = self.iam.create_role(
            RoleName=role_name,
            AssumeRolePolicyDocument=json.dumps(trust_policy),
            Description=f"Cross-account role for {role_name} from hub {self.hub_account_id}",
            MaxSessionDuration=3600,
            Tags=[
                {'Key': 'ManagedBy', 'Value': 'SecurityTeam'},
                {'Key': 'CrossAccountHub', 'Value': self.hub_account_id},
                {'Key': 'Purpose', 'Value': 'SecurityScanning'}
            ]
        )

        # Attach least-privilege policy
        self.iam.attach_role_policy(
            RoleName=role_name,
            PolicyArn='arn:aws:iam::policy/SecurityAudit'
        )

        return role['Role']['Arn']

    def assume_spoke_role(self, spoke_account_id, role_name, external_id=None):
        """Assume a role in a spoke account with session tagging."""
        role_arn = f"arn:aws:iam::{spoke_account_id}:role/{role_name}"

        params = {
            'RoleArn': role_arn,
            'RoleSessionName': f"hub-session-{spoke_account_id}",
            'DurationSeconds': 3600,
            'Tags': [
                {'Key': 'SourceAccount', 'Value': self.hub_account_id},
                {'Key': 'AccessPurpose', 'Value': 'SecurityAudit'}
            ]
        }

        if external_id:
            params['ExternalId'] = external_id

        credentials = self.sts.assume_role(**params)
        return boto3.Session(
            aws_access_key_id=credentials['Credentials']['AccessKeyId'],
            aws_secret_access_key=credentials['Credentials']['SecretAccessKey'],
            aws_session_token=credentials['Credentials']['SessionToken']
        )

Key design decisions in this pattern: limit MaxSessionDuration to one hour for security scanning roles, use session tags to propagate context about why the role was assumed, and always include the aws:PrincipalOrgID condition to prevent roles from being assumed by principals outside your organization, even if the hub account is compromised.

External IDs and the Confused Deputy Problem

Why External IDs Matter

When a third-party service assumes a role in your account, nothing in the basic trust policy prevents another customer of that same service from tricking it into assuming your role on their behalf. The external ID solves this by acting as a shared secret between you and the third party.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:root"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "accesslens-unique-per-customer-a8f3b2c1"
        }
      }
    }
  ]
}

Critical rules for external IDs: generate a unique external ID per customer relationship (never reuse across customers), make the external ID unpredictable (UUIDs work well), and never let the assuming party choose their own external ID. The external ID is not a secret in the cryptographic sense, but it must be unique and unguessable.

Resource-Based Policies vs Role Assumption

When to Use Each Pattern

Resource-based policies allow cross-account access without role assumption. The calling principal retains its original identity, which simplifies CloudTrail auditing since you see the original principal ARN rather than an assumed role session.

#!/bin/bash
# Grant cross-account access to an S3 bucket using a resource-based policy

aws s3api put-bucket-policy --bucket security-audit-logs --policy '{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCrossAccountSecurityRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::999888777666:role/SecurityAnalyzer"
      },
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::security-audit-logs",
        "arn:aws:s3:::security-audit-logs/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:PrincipalOrgID": "o-abc123def4"
        }
      }
    }
  ]
}'

# Grant cross-account KMS key access for encrypted bucket objects
aws kms put-key-policy --key-id "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123" \
  --policy-name default \
  --policy '{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCrossAccountDecrypt",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::999888777666:role/SecurityAnalyzer"
      },
      "Action": ["kms:Decrypt", "kms:DescribeKey"],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalOrgID": "o-abc123def4"
        }
      }
    }
  ]
}'

Use resource-based policies when: the service supports them (S3, KMS, SQS, SNS, Lambda, Secrets Manager), you want the original principal identity preserved in logs, and the access pattern is resource-specific. Use role assumption when: you need to call multiple services in the target account, the service does not support resource-based policies, or you need to scope permissions dynamically with session policies.

Organizations-Based Trust Boundaries

Leveraging Organization Structure

AWS Organizations provides condition keys that let you scope trust to your entire organization or specific organizational units. The aws:PrincipalOrgID condition is the single most important cross-account security control available. It ensures that even if an account ID is correctly specified in a trust policy, the assuming principal must belong to your organization.

For more granular control, use aws:PrincipalOrgPaths to restrict trust to specific OUs. This is useful when your security account should only scan production accounts, or when development accounts should never be able to assume roles in production OUs.

Auditing Cross-Account Access

Query CloudTrail for AssumeRole events across all accounts using CloudTrail Lake or Athena. Focus on role assumptions where the source account is outside your organization, where the assumed role has administrative permissions, or where the session duration exceeds your standard policy. IAM Access Analyzer's external access findings surface any resource or role that can be accessed from outside your organization, and should be reviewed weekly.

Securing Cross-Account Access with AccessLens

Cross-account access patterns are among the most complex and risk-prone aspects of AWS IAM. A single overly permissive trust policy can expose an entire account to unauthorized access. As the number of accounts and cross-account relationships grows, manual review becomes impossible.

AccessLens was built specifically for this challenge:

  • Trust relationship visualization that maps every cross-account role assumption path across your organization as an interactive graph
  • External access detection that identifies roles assumable from outside your AWS Organization
  • External ID validation that verifies all third-party cross-account roles require external IDs
  • Permission chain analysis that traces what a cross-account role can actually do after assumption, including transitive access
  • Drift alerting that notifies you immediately when trust policies are modified

Cross-account access is where IAM complexity creates the most dangerous blind spots. Visibility into these trust relationships is not optional at scale.

Map your cross-account trust relationships with AccessLens and get the visibility you need to secure IAM access across every account in your organization.

Ready to secure your AWS environment?

Get comprehensive IAM visibility across all your AWS accounts in minutes.