AWS Identity

Secure Cross-Account Access: Assume-Role Patterns, External ID, Confused Deputy, and Session Policies

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:

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:

  1. Account B’s role trust policy must Allow the Account A principal to call sts:AssumeRole on the role.
  2. Account A’s identity policy (on the user/role doing the assuming) must Allow sts:AssumeRole against 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 grant sts:AssumeRole to 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:

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 or aws:PrincipalOrgID constraint 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:

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:

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:

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:

CloudTrail’s sharedEventID is 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.

Checklist

awsiamstscross-accountsecuritysession-policies

Comments

Keep Reading