AWS Networking & Identity

Cross-Account Access & STS

18 min Lesson 8 of 28

Cross-Account Access & STS

At big-tech scale, a single AWS account is an anti-pattern. Production, staging, security, logging, and shared services live in separate accounts inside an AWS Organization. Engineers, CI/CD pipelines, and services must cross those account boundaries constantly — and the mechanism that makes all of it possible without sharing long-lived credentials is AWS Security Token Service (STS) combined with IAM AssumeRole.

Why Separate Accounts?

Account-level isolation is the strongest blast-radius control AWS offers. A compromised credential in staging cannot touch production data. Service Control Policies (SCPs) at the Organization level enforce guardrails that not even the account root can override. Cost allocation, CloudTrail, and AWS Config run per-account, giving clean audit trails. The standard pattern is: one management account for Organizations only, one security/audit account, one shared-services account (DNS, ECR, CI/CD tooling), and separate workload accounts per team or environment.

The AssumeRole Flow

The core mechanic is a four-step chain: a principal (IAM user, role, or AWS service) in Account A calls sts:AssumeRole targeting a role ARN in Account B. STS validates that the trust policy on the target role permits the caller, issues short-lived credentials (access key ID, secret access key, session token) valid for 15 minutes to 12 hours, and returns them. The caller embeds those credentials in subsequent API calls — AWS evaluates them against the target role's permission policies in Account B.

Cross-Account AssumeRole Flow Account A (Dev / CI) Caller Identity IAM Role / User / EC2 Instance 1. sts:AssumeRole(RoleArn, ExternalId) AWS STS Validates trust policy 2. Temp credentials (AK + SK + Token) 3. API call with temp creds Account B (Prod) Target IAM Role arn:aws:iam::PROD:role/DeployRole Trust Policy Principal: Account A role ARN Permission Policy s3:GetObject, ecr:BatchGetImage… 4. AWS evaluates temp creds against the Permission Policy in Account B
Four-step cross-account AssumeRole flow: the caller in Account A requests temporary credentials from STS, then uses them to invoke APIs in Account B under the target role's permissions.

Trust Policies — The Door

A trust policy is the resource-based policy attached to the role itself that answers: "who is allowed to assume this role?" It uses the sts:AssumeRole action with a Principal element naming the trusted entity — an account ID, a specific role ARN, or an AWS service.

{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowDevCICDRole", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:role/CICDDeployRole" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "prod-deploy-external-id-abc123" } } } ] }
Two policies, two jobs: the trust policy controls who can assume the role (the door); the permission policy controls what they can do once inside (the room). Both must be correct for access to succeed.

External IDs — The Confused Deputy Defense

Imagine you build a SaaS tool that assumes a role in your customer's AWS account to read CloudWatch metrics. You configure the trust policy to trust your SaaS's AWS account. Now consider: a different customer could trick your SaaS into using its own legitimate credentials to assume your first customer's role by supplying that role ARN. This is the confused deputy attack.

The fix is an External ID: a shared secret value that both you (the SaaS) and the customer (who writes the trust policy) agree on, unique per customer. When your service calls AssumeRole it must supply the correct ExternalId for that customer, and the trust policy enforces it with a StringEquals condition. A malicious caller who does not know the External ID cannot trigger a successful assumption.

External ID best practice: generate a random UUID per customer and store it on your side. Use it in the Condition block of the trust policy. Treat it as a secret — never expose it in logs or error messages. Rotate it if a customer offboards and re-onboards.

Assuming a Role from the CLI

Use aws sts assume-role to get temporary credentials, then export them as environment variables for subsequent CLI calls.

# Assume the role and capture credentials CREDS=$(aws sts assume-role \ --role-arn "arn:aws:iam::444455556666:role/ProdReadOnly" \ --role-session-name "developer-audit-$(date +%s)" \ --external-id "prod-deploy-external-id-abc123" \ --duration-seconds 3600 \ --query "Credentials" \ --output json) # Export into the shell export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .AccessKeyId) export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .SecretAccessKey) export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .SessionToken) # Verify — should show the assumed role ARN aws sts get-caller-identity # Unset when done unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

In CI/CD systems (GitHub Actions, Jenkins, GitLab CI) you never do this manually. Instead you configure the job runner to use aws-actions/configure-aws-credentials (GitHub Actions) or an EC2 instance role, and the SDK handles token refresh automatically.

Session Policies — Scoping Down at Assume Time

When calling AssumeRole you may pass an optional session policy: an inline JSON policy that further restricts the effective permissions for this session. It cannot grant permissions beyond what the role itself allows — the effective permission set is always the intersection of the role's policies and the session policy. This is powerful for least-privilege: a general-purpose role with broad read permissions can be scoped to a single S3 bucket for a specific automated task.

# Assume a role but scope down to a single bucket for this session aws sts assume-role \ --role-arn "arn:aws:iam::444455556666:role/S3ReadAll" \ --role-session-name "etl-job-nightly" \ --policy '{ "Version":"2012-10-17", "Statement":[{ "Effect":"Allow", "Action":["s3:GetObject","s3:ListBucket"], "Resource":[ "arn:aws:s3:::my-prod-data-bucket", "arn:aws:s3:::my-prod-data-bucket/*" ] }] }' \ --duration-seconds 900
Common production failure: role chaining limits. When you assume Role A and then from that session assume Role B (role chaining), the maximum session duration is capped at 1 hour regardless of what the role allows. This breaks long-running CodeBuild jobs or Terraform runs that try to chain roles. The fix is to have the root principal assume the final role directly, skipping intermediate hops.

Cross-Account Role Patterns in Production

The hub-and-spoke pattern is standard: a central CI/CD role in the shared-services account has trust from each workload account's DeployRole. The pipeline assumes the target account's role directly — no chaining. For read-only audit access, a dedicated security account role is trusted by all workload accounts, giving the security team a single place to initiate cross-account reads without any standing access.

For AWS Organizations, SCPs add a third layer: even if a trust policy and permission policy both allow an action, an SCP denying it at the OU level will block it. Always validate cross-account access with aws iam simulate-principal-policy after SCP changes.

Never use long-lived keys for cross-account work. Attach an IAM role to your EC2 instance, Lambda function, ECS task, or CodeBuild project. The SDK fetches temporary credentials from the instance metadata service automatically. Long-lived access keys that escape into a git repo or a log line are the single largest source of AWS security incidents.