Cross-account access is where most AWS IAM accidents are born. A role that exists only to let a CI pipeline read an artifact bucket quietly becomes a path into your production account because someone wrote Principal: "*" in a trust policy and bolted on an ExternalId they never validated. The mechanics of sts:AssumeRole are simple; getting the authorization, the confused deputy defenses, and the privilege scoping right is not. This guide walks the full path: how the two policies on a role actually combine, how to harden trust for third parties and for your own org, and how to scope a delegated session down to exactly what it needs and no more.
Everything here assumes a multi-account org and the regional STS endpoint (sts.<region>.amazonaws.com), not the legacy global one.
1. Two policies, one role: the AssumeRole authorization flow
Every IAM role carries two distinct policy documents, and conflating them is the root cause of most cross-account confusion:
- The trust policy (
AssumeRolePolicyDocument) — a resource-based policy that answers “who may assume this role.” ItsPrincipalelement names the allowed identity. - The permission policy (identity-based policies attached to the role) — answers “what can the role do once assumed.”
For a principal in Account A to assume a role in Account B, two authorizations must both succeed, because this is a cross-account call:
- Account B’s role trust policy must
Allowthe Account A principal to callsts:AssumeRoleon the role. - Account A’s identity policy (on the user/role doing the assuming) must
Allowsts:AssumeRoleagainst the target role ARN.
Within a single account, a resource-based policy that grants access is sufficient on its own. Across accounts it is not — the calling principal also needs an explicit identity-based Allow. Miss either side and you get AccessDenied. This is the single most common cross-account stumbling block.
// Account B: trust policy on role "PlatformDeploy" (arn role in 222222222222)
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:role/ci-runner" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:PrincipalOrgID": "o-abc123example" }
}
}]
}
// Account A (111111111111): identity policy attached to role "ci-runner"
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::222222222222:role/PlatformDeploy"
}]
}
Naming the role ARN as
Principal(not the root account) means you are trusting a specific identity.Principal: { "AWS": "arn:aws:iam::111111111111:root" }delegates the trust decision to Account A’s IAM admins — anyone they grantsts:AssumeRoleto can get in. That is sometimes deliberate (you want Account A to self-manage), but be explicit about which you chose.
The returned credentials are an AssumedRole principal of the form arn:aws:sts::222222222222:assumed-role/PlatformDeploy/<session-name>. The RoleSessionName you pass becomes part of that ARN and shows up in CloudTrail and in aws:userid, which is why you should always set it to something meaningful.
2. The confused deputy problem and ExternalId
The classic confused deputy appears with third-party / SaaS integrations. You grant a vendor’s AWS account permission to assume a role in your account so their service can, say, scan your config. The vendor’s account is multi-tenant: it assumes roles into all their customers’ accounts. If the vendor names only your role ARN and account, an attacker who is also a customer of that vendor could trick the vendor’s service into assuming your role — the vendor is the confused deputy, holding privileges it is fooled into misusing on the attacker’s behalf.
The fix is sts:ExternalId: a shared secret the vendor stores per-customer and passes on every AssumeRole. Your trust policy requires a specific value, so the deputy cannot be coerced into using your role unless it presents the exact ID the vendor associated with you.
// Trust policy for a third-party SaaS role (vendor account 999999999999)
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::999999999999:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "kloudvin-prod-7f3c9a1e-do-not-share" }
}
}]
}
Rules that matter in practice:
- The vendor generates and supplies the
ExternalId(often your account ID or a per-customer UUID). You do not invent it; you paste what they give you, and you let them own uniqueness across their tenants. - It is not a secret credential — it travels in API calls and is not high-entropy by design. It defeats confusion, not a determined attacker who already has the value. Do not rely on it for accounts you control.
- For roles you assume yourself across your own org,
ExternalIdis the wrong tool. Useaws:PrincipalOrgIDand source conditions (next section). ExternalId exists specifically for the multi-tenant-deputy shape.
3. Hardening trust: PrincipalOrgID, SourceArn, SourceAccount
For internal cross-account roles, three condition keys do the heavy lifting:
| Key | Type | Use it when |
|---|---|---|
aws:PrincipalOrgID |
Global | You want any principal in your AWS Organization, present or future, without enumerating account IDs. |
aws:SourceAccount |
Global | An AWS service (not a principal) assumes/uses the role on behalf of a resource; pin the owning account. |
aws:SourceArn |
Global | Same service case, but pin the exact resource ARN that may trigger the action. |
aws:PrincipalOrgID is the clean way to scope “anyone in my org.” It evaluates the org of the calling principal, so you do not maintain an account-ID allowlist as the org grows:
"Condition": {
"StringEquals": { "aws:PrincipalOrgID": "o-abc123example" }
}
The SourceArn / SourceAccount pair is the confused-deputy defense for the service-as-deputy case — e.g. a role assumed by CloudWatch, Config, or a cross-service integration. Here the deputy is an AWS service, and ExternalId does not apply because services do not pass it. Pin the source instead:
// Trust policy for a role assumed by an AWS service on behalf of one resource
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "events.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:SourceAccount": "111111111111" },
"ArnLike": {
"aws:SourceArn": "arn:aws:events:us-east-1:111111111111:rule/*"
}
}
}]
}
Combine, don’t choose. A production third-party role often carries both
sts:ExternalId(confused-deputy defense) and an IP oraws:PrincipalOrgIDconstraint where applicable. Each condition is ANDed within a statement, so every one must pass.
4. Scoping down the session: session policies and managed-policy ARNs
A role’s permission policy defines the ceiling. Often you want a single session to operate well below that ceiling — broker out narrow credentials from a broad role. That is what session policies are for. They are passed at assume time and the effective permissions are the intersection of the role’s identity policies and the session policy. A session policy can only subtract; it never grants beyond the role.
Two ways to pass them:
# Inline session policy (JSON), intersected with the role's permissions
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/PlatformDeploy \
--role-session-name deploy-svc-7421 \
--policy '{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":["s3:GetObject","s3:PutObject"],
"Resource":"arn:aws:s3:::artifacts-222222222222/builds/*"
}]
}'
# Managed-policy ARNs as session policies (up to 10)
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/PlatformDeploy \
--role-session-name deploy-svc-7421 \
--policy-arns arn=arn:aws:iam::222222222222:policy/ScopedDeployS3 \
arn=arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess
Constraints worth committing to memory:
- You can pass up to 10 managed-policy ARNs via
--policy-arns, optionally plus one inline--policy. All are intersected with the role. - An explicit
Denyin a session policy always wins — useful for a hard “never touch X” guardrail even inside a broad broker role. - Everything (inline policy + ARNs + session tags) is compressed into a packed binary; the response includes
PackedPolicySizeas a percentage of the limit. If you are near 100%, prefer managed-policy ARNs (which count lighter) over a large inline blob. - Managed session policies must live in the same account as the role being assumed.
This pattern is how you build a credential broker: one trusted role with a moderate ceiling, and a service that mints tightly-scoped, short-lived sessions per request.
5. Propagating identity: source identity and chaining limits
When a human or workload assumes a role, the downstream actor is just an assumed-role ARN — you lose the original identity. Source identity fixes this. --source-identity stamps an immutable string onto the session that:
- Cannot be changed for the life of the session (unlike
RoleSessionName), and - Persists across role chaining — assume a second role and the source identity carries through unchanged.
That immutability is what makes it usable for attribution and for aws:SourceIdentity-based access control. To set it, the assuming principal needs sts:SetSourceIdentity in its permission policy and in the target role’s trust policy. In a chain, the next role’s trust policy must also allow sts:SetSourceIdentity or the chained assume fails with AccessDenied.
// Trust policy that requires source identity to be set and well-formed
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:role/ci-runner" },
"Action": "sts:AssumeRole",
"Condition": {
"StringLike": { "sts:SourceIdentity": "*@kloudvin.com" }
}
},
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:role/ci-runner" },
"Action": "sts:SetSourceIdentity"
}
]
}
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/PlatformDeploy \
--role-session-name deploy-svc-7421 \
--source-identity vinod@kloudvin.com
You can later require that source identity downstream — e.g. only sessions whose aws:SourceIdentity matches a corporate identity may touch a sensitive resource. This is how you tie an assumed-role action all the way back to a person.
6. Session tags and ABAC across accounts
AssumeRole can attach session tags that participate in authorization via aws:PrincipalTag/<key>. This is the backbone of cross-account ABAC: instead of writing per-team policies, you write one policy that says “you may act on resources whose project tag equals your session’s project tag.”
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/PlatformDeploy \
--role-session-name deploy-svc-7421 \
--tags Key=project,Value=atlas Key=env,Value=prod \
--transitive-tag-keys project env
// Permission policy on the assumed role: ABAC against the session tag
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/project": "${aws:PrincipalTag/project}"
}
}
}]
}
Two things make or break cross-account ABAC:
- Transitive tags. Plain session tags do not survive role chaining. List them under
--transitive-tag-keysand they propagate into every subsequent assumed session in the chain — and become immutable: a downstream assume cannot override a transitive tag’s value. - Trust-policy gating. To set a tag at all, the trust policy must allow
sts:TagSession. You can also constrain which tags or values are acceptable withaws:RequestTag/<key>conditions, so a caller cannot self-assignenv=prod.
7. Role chaining pitfalls: the one-hour ceiling
Role chaining — using one role’s temporary credentials to assume another role — has a hard limit that surprises teams in production: the chained session is capped at one hour. If you call AssumeRole with credentials that already came from an assumed role and pass DurationSeconds greater than 3600, the call fails.
The full picture:
| Scenario | Max DurationSeconds |
|---|---|
| Assume from an IAM user or root | up to the role’s MaxSessionDuration (1–12h) |
| Assume from an EC2 instance profile | governed by instance metadata, not the chaining cap |
| Role chaining (assumed-role creds -> assume another role) | 1 hour, hard cap |
MaxSessionDuration (settable 3600–43200s) defines the ceiling for a role, but it does not lift the chaining cap. So a role configured for 12 hours still yields only a 1-hour session when reached via chaining. Design implication: long-running workloads that chain should re-assume on a refresh loop (the SDKs’ credential providers do this automatically) rather than expecting a 12-hour session. Also note credentials minted by an assumed-role session cannot call GetFederationToken or GetSessionToken — another reason to refresh by re-assuming.
8. Verify
Confirm the wiring before trusting it. Assume the role and inspect what you actually got:
# 1. Assume and capture creds in one shot
eval "$(aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/PlatformDeploy \
--role-session-name verify-7421 \
--source-identity vinod@kloudvin.com \
--query 'Credentials.[`export AWS_ACCESS_KEY_ID=`+AccessKeyId,
`export AWS_SECRET_ACCESS_KEY=`+SecretAccessKey,
`export AWS_SESSION_TOKEN=`+SessionToken]' \
--output text | tr '\t' '\n')"
# 2. Confirm the resulting principal ARN and account
aws sts get-caller-identity
# Expect: "Arn": "arn:aws:sts::222222222222:assumed-role/PlatformDeploy/verify-7421"
Test the negative path too — the security control only works if denials happen:
# Should FAIL if ExternalId is required and omitted
aws sts assume-role \
--role-arn arn:aws:iam::222222222222:role/ThirdPartyScan \
--role-session-name no-extid
# Expect: AccessDenied (no matching trust statement)
Use the IAM policy simulator to validate intersection logic against a session policy before shipping it:
aws iam simulate-custom-policy \
--policy-input-list file://role-permissions.json \
--permissions-boundary-policy-input-list file://session-policy.json \
--action-names s3:DeleteObject \
--resource-arns "arn:aws:s3:::artifacts-222222222222/builds/x"
# Treat the session policy as the bounding input; expect implicitDeny for actions outside the intersection
Finally, confirm the audit trail exists. After an assume, the STS call lands in CloudTrail with eventName: AssumeRole, the RoleSessionName and sourceIdentity in requestParameters, and — for a cross-account assume — one event in each account joined by the same sharedEventID. Every downstream action by that session carries sourceIdentity inside userIdentity.sessionContext.
9. Auditing and anomaly detection in CloudTrail
The two questions an auditor asks are “who really did this?” and “is this assume normal?” Source identity answers the first; baseline analysis answers the second. If you have CloudTrail in an Athena table (or CloudTrail Lake), this Athena SQL surfaces cross-account assumes and the identity behind them:
-- Cross-account AssumeRole calls in the last 24h, with origin identity
SELECT
eventtime,
useridentity.accountid AS calling_account,
recipientaccountid AS target_account,
json_extract_scalar(requestparameters, '$.roleArn') AS role_arn,
json_extract_scalar(requestparameters, '$.sourceIdentity') AS source_identity,
sourceipaddress
FROM cloudtrail_logs
WHERE eventsource = 'sts.amazonaws.com'
AND eventname = 'AssumeRole'
AND useridentity.accountid <> recipientaccountid
AND from_iso8601_timestamp(eventtime) > current_timestamp - interval '1' day
ORDER BY eventtime DESC;
Signals that deserve an alert:
- An
AssumeRolewheresourceIdentityis null on a role whose trust policy was supposed to require it — a sign the requirement is missing or bypassed. - A cross-account assume from a
sourceIpAddressoutside your known NAT/egress ranges or corporate CIDRs. - A spike in
AssumeRolevolume against a sensitive role, or aRoleSessionNamepattern you have never seen. AccessDeniedSTS events clustering on one role ARN — someone probing a trust policy.
CloudTrail’s
sharedEventIDis the join key for reconstructing a cross-account chain: pivot from the assume event in the target account to the matching one in the calling account to see the full origin, even when the calling account is one you do not own end-to-end.
Enterprise scenario
A platform team running a multi-tenant data product had a “report exporter” microservice in a shared services account (555555555555). On request, it assumed an ExportRole in each tenant account to read that tenant’s S3 bucket and write a signed export. The role’s permission policy granted s3:GetObject on arn:aws:s3:::* — broad by design, because the exporter served hundreds of tenants and nobody wanted per-tenant policy edits.
The constraint surfaced in a pen test: the exporter accepted a tenant_id from the incoming request and used it to build the bucket name. A crafted request with a different tenant’s ID made the service read another tenant’s bucket — a textbook confused deputy, except the deputy was their own service and the broad s3:* ceiling made the blast radius the entire fleet. ExternalId did not apply (these were internal accounts), and they could not enumerate per-tenant policies.
They fixed it with session policies plus source identity, scoping each assume to exactly the requesting tenant at mint time:
# Exporter mints a per-request session scoped to ONE tenant's bucket
aws sts assume-role \
--role-arn "arn:aws:iam::${TENANT_ACCOUNT}:role/ExportRole" \
--role-session-name "export-${REQUEST_ID}" \
--source-identity "exporter-svc" \
--duration-seconds 900 \
--policy "{
\"Version\":\"2012-10-17\",
\"Statement\":[{
\"Effect\":\"Allow\",
\"Action\":\"s3:GetObject\",
\"Resource\":\"arn:aws:s3:::tenant-${TENANT_ID}-exports/*\"
}]
}"
Because effective permissions are the intersection, the broad s3:GetObject ceiling on ExportRole was now clamped to the single bucket named in the session policy — a forged tenant_id could no longer reach any other tenant’s data, because the credentials themselves could not. They also moved each tenant’s trust policy to require aws:SourceAccount = 555555555555 so only the shared exporter (not a stray role in the tenant account) could assume ExportRole, dropped the session to 15 minutes, and added a CloudTrail alert on any ExportRole assume lacking sourceIdentity = exporter-svc. The ceiling stayed broad and edit-free; the session became surgical.