Cross-account AssumeRole: trust policies, external IDs, tags, and fixes
This guide is for developers and platform engineers wiring one AWS account to access resources in another without long-lived keys. It explains how cross-account AssumeRole actually works, how trust policies, external IDs, and session tags fit together, and how to diagnose the AccessDenied errors and confused-deputy mistakes that show up in real systems.
TL;DR — Cross-account access with AssumeRole is a two-sided contract: the caller needs permission to call
sts:AssumeRole, and the target role’s trust policy must explicitly trust that caller. If a third party or multi-tenant service assumes roles in customer accounts, add ansts:ExternalIdcondition to the trust policy; if you need per-request authorization context, pass session tags and authorize onaws:PrincipalTagin downstream policies. Reading time: ~7 min
What it is and where it sits
Cross-account assume-role is the standard way to let code in Account A temporarily act as a role in Account B without copying access keys around. The moving parts are simple but easy to miswire:
- A principal in the source account calls STS
AssumeRole. - STS evaluates two policy planes:
- the caller’s identity policy, which must allow
sts:AssumeRoleon the target role ARN - the target role’s trust policy, which must trust that caller and any conditions like
sts:ExternalIdor tag requirements
- the caller’s identity policy, which must allow
- If allowed, STS returns temporary credentials.
- Those temporary credentials are then used against the target account’s services, where normal identity/resource policies apply.
This replaces patterns you should avoid in 2026:
- long-lived IAM user access keys shared between accounts
- “central admin account” scripts using static credentials copied into CI/CD
- broad organization-wide trust with no tenant boundary for third-party access
Architecture-wise, it sits between your workload and the target account’s APIs. STS is the broker; the role trust policy is the entry gate; the role’s permissions policy is what the session can actually do after entry.
[App/CI in Account A]
|
| 1. sts:AssumeRole(RoleArn, ExternalId?, Tags?)
v
[AWS STS]
|
| 2. Check caller identity policy
| 3. Check target role trust policy
v
[Temporary role session in Account B]
|
| 4. Call S3/KMS/RDS/etc using temp creds
v
[Target resources in Account B]
The confused deputy problem appears when a third-party service in Account A assumes roles in many customer accounts. If customer X only trusts “the vendor account” and not a customer-specific external ID, customer Y may trick that vendor into using the same vendor principal to access customer X’s role. The vendor is the “deputy”; the external ID is the tenant-specific proof that the request is for the right customer.
How it actually works
Walk one realistic example: your SaaS control plane runs in account 111111111111. A customer gives you a role in their account 222222222222 so your collector can read specific S3 inventory objects. You want tenant isolation and auditability.
Step 1: customer creates the target role in their account
The role has:
- a trust policy allowing your vendor role to assume it
- a condition requiring the customer-specific external ID
- optionally, permission to pass session tags only if you want tag-aware authorization later
- a permissions policy allowing only the target S3 bucket/prefix
Trust policy in customer account:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111111111111:role/vendor-control-plane"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "cust-7f3c2b1a"
},
"ForAllValues:StringEquals": {
"sts:TagKeys": ["tenant", "purpose"]
},
"StringEqualsIfExists": {
"aws:RequestTag/tenant": "cust-7f3c2b1a",
"aws:RequestTag/purpose": "inventory-read"
}
}
}
]
}
What this does:
- only your specific role can assume it, not the whole vendor account root
- the caller must present
ExternalId=cust-7f3c2b1a - if the caller passes tags, only
tenantandpurposeare allowed, and their values are constrained
Step 2: your vendor role must be allowed to call AssumeRole
In your account, the calling role needs an identity policy like:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Resource": "arn:aws:iam::222222222222:role/customer-inventory-read"
}
]
}
Missing sts:TagSession is a common gotcha: the assume may fail only when you start passing tags.
Step 3: call STS with external ID and tags
From your workload or CLI:
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/customer-inventory-read \
--role-session-name inv-sync-cust-7f3c2b1a \
--external-id cust-7f3c2b1a \
--tags tenant=cust-7f3c2b1a purpose=inventory-read \
--transitive-tag-keys tenant \
--duration-seconds 3600
Typical success output shape:
{
"Credentials": {
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJalr...",
"SessionToken": "IQoJb3JpZ2luX2VjE...",
"Expiration": "2026-08-05T14:22:31+00:00"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROA...:inv-sync-cust-7f3c2b1a",
"Arn": "arn:aws:sts::222222222222:assumed-role/customer-inventory-read/inv-sync-cust-7f3c2b1a"
},
"PackedPolicySize": 6
}
Typical failure if external ID is missing or wrong:
$ aws sts assume-role --role-arn arn:aws:iam::222222222222:role/customer-inventory-read --role-session-name test
An error occurred (AccessDenied) when calling the AssumeRole operation: User: arn:aws:sts::111111111111:assumed-role/vendor-control-plane/app is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::222222222222:role/customer-inventory-read
That error text is annoyingly generic. It can mean any of these:
- caller identity policy lacks
sts:AssumeRole - target trust policy does not trust the caller ARN you actually used
- trust policy requires
sts:ExternalIdand you omitted or mismatched it - you passed tags but lack
sts:TagSession, or the trust policy rejects the tag keys/values
Step 4: use the temporary credentials
Export them and call the target service:
export AWS_ACCESS_KEY_ID="ASIA..."
export AWS_SECRET_ACCESS_KEY="wJalr..."
export AWS_SESSION_TOKEN="IQoJb3JpZ2luX2VjE..."
aws s3api list-objects-v2 \
--bucket customer-inventory-bucket \
--prefix exports/daily/
Now the role’s permissions policy and any resource policy decide what happens. The trust policy is no longer involved after the session is issued.
Step 5: session tags flow into authorization and audit
If the target account writes policies against aws:PrincipalTag/tenant, the temporary session carries that context. Example S3 bucket policy pattern:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::customer-inventory-bucket/exports/daily/*",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/tenant": "cust-7f3c2b1a"
}
}
}
]
}
This is useful when one role can be assumed for multiple tenants but access must still be scoped per request. It is also visible in CloudTrail, which helps answer “which tenant context was this session operating under?”
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| Your app in one AWS account needs temporary access to resources in another AWS account | Use cross-account AssumeRole |
| A third-party SaaS accesses many customer accounts | Use AssumeRole with a unique sts:ExternalId per customer |
| You need per-request tenant or environment context in authorization decisions | Pass session tags and authorize on aws:PrincipalTag/* |
| Two accounts are both yours and managed in one organization, with stable automation | Still use AssumeRole; external ID is usually not needed unless a third party is involved |
| You were about to create IAM users and share access keys across accounts | Don’t; replace with AssumeRole |
| You only need humans to switch roles in the console occasionally | Use role switching or federation; session tags may be optional |
| You need access from outside AWS and cannot use AWS-native principals | You probably need OIDC/SAML/web identity federation first, then assume a role |
| You want to solve service-to-service auth inside one account | You probably don’t need cross-account assume-role; use normal same-account roles |
You probably don’t need external IDs if the caller and target are both under your control and there is no multi-tenant third-party deputy. External ID is not a secret password; it is a tenant-binding control against confused deputy, mainly for third-party access patterns.
Trade-offs
-
Temporary credentials instead of static keys
- Benefit: less key sprawl, shorter blast radius, easier rotation
- Cost: every client needs STS call logic, caching, and refresh handling
-
Trust policy plus caller policy
- Benefit: explicit bilateral authorization
- Cost: two places to debug;
AccessDeniedoften hides which side failed
-
External ID
- Benefit: mitigates confused deputy in multi-tenant third-party access
- Cost: customer onboarding complexity; you must generate, store, and pass the right ID every time
-
Session tags
- Benefit: richer authorization and audit context without role explosion
- Cost: more policy complexity, tag governance, and packed session size limits
-
Fine-grained roles per customer
- Benefit: simpler reasoning, narrower blast radius
- Cost: more IAM objects and lifecycle management
-
One shared role plus tags
- Benefit: fewer roles to manage
- Cost: easier to misconfigure; a bad tag condition can widen access unexpectedly
Latency and cost are usually minor but real. STS adds an extra API call before work starts. In high-churn short-lived jobs, credential caching matters. Operationally, CloudTrail and IAM policy simulation become part of your debugging toolbox.
In practice
Example 1: create the trust policy and role from the CLI
⚠️ Updating a role trust policy takes effect immediately. If this role is used in production automation, a bad policy can break all cross-account access until fixed.
cat > trust-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111111111111:role/vendor-control-plane"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "cust-7f3c2b1a"
},
"ForAllValues:StringEquals": {
"sts:TagKeys": ["tenant", "purpose"]
},
"StringEqualsIfExists": {
"aws:RequestTag/tenant": "cust-7f3c2b1a",
"aws:RequestTag/purpose": "inventory-read"
}
}
}
]
}
JSON
aws iam create-role \
--role-name customer-inventory-read \
--assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy \
--role-name customer-inventory-read \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
This creates the target role and attaches a broad managed policy for brevity. The gotcha: managed policies are often broader than you want; replace with an inline or customer-managed policy scoped to the exact bucket/prefix.
Example 2: assume the role and export credentials safely in shell
read AK SK ST <<<"$(aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/customer-inventory-read \
--role-session-name inv-sync-cust-7f3c2b1a \
--external-id cust-7f3c2b1a \
--tags tenant=cust-7f3c2b1a purpose=inventory-read \
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \
--output text)"
export AWS_ACCESS_KEY_ID="$AK"
export AWS_SECRET_ACCESS_KEY="$SK"
export AWS_SESSION_TOKEN="$ST"
aws sts get-caller-identity
This is the fastest way to verify the session you got is really in the target account. The gotcha: shell history and process inspection can leak credentials; prefer a subshell, short-lived environment, or SDK credential providers in production.
Example 3: diagnose the exact caller ARN you need to trust
aws sts get-caller-identity
Typical output:
{
"UserId": "AROAEXAMPLE123:app",
"Account": "111111111111",
"Arn": "arn:aws:sts::111111111111:assumed-role/vendor-control-plane/app"
}
Use this to confirm what principal is actually making the call. The gotcha: trust policies usually name the IAM role ARN (arn:aws:iam::...:role/...), while runtime identity often shows an STS assumed-role ARN (arn:aws:sts::...:assumed-role/...). Trust the underlying IAM role, not the session ARN.
Further reading
- IAM User Guide: "Update a role trust policy"
- IAM User Guide: "External IDs for third party access"
- IAM User Guide: "Passing session tags in AWS STS"
- STS API Reference: "AssumeRole"
- AWS Security Blog article "How to Use External ID When Granting Access to Your AWS Resources to a Third Party"
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI