Australian cloud for business software — built and run in Sydney since 2003

NetLeverage
NetLeverage

IAM cross-account access: trust policies, permission boundaries and how to debug a denial

Technical article
Cloud

About this article: AI-assisted writing, reviewed against the cited sources. This is source research, not a lab validation of the procedures. Check the stated versions and your own recovery requirements before making changes.

A cross-account request succeeds only if two separate evaluations both return Allow: one in the account holding the principal, one in the account holding the resource. A denial can therefore come from either side, or from a guardrail such as a service control policy or permission boundary that sits outside both the caller's policy and the resource's policy.

The fastest diagnosis is to read the denial message for the policy type it names, confirm which identity actually made the call, and then check each side in order. Commands below were written for this article from the AWS references. None was run in a lab.

Why it matters

Separate accounts are a common way to isolate production from development, and a shared backup or logging account depends on cross-account access working every time. A denial during a restore or a deployment is an expensive moment to learn how the evaluation works.

The opposite failure is quieter. A trust policy that names a whole account, paired with a caller policy using "Resource": "*", can grant far more than anyone intended.

How it works

Two accounts, two evaluations

AWS calls the account holding the principal the trusted account and the account holding the resource the trusting account. It evaluates the request in both, and allows it only if both evaluations return Allow. An explicit deny on either side ends the request.

The requester needs an identity-based policy allowing the call. The resource needs a resource-based policy naming that requester. A role's trust policy is its resource-based policy.

This differs from a single account. In one account, an Allow in either the identity or the resource policy is usually enough, but role trust policies and KMS key policies are exceptions that must explicitly allow. Habits formed inside one account therefore mislead when a second account appears.

Delegation also works only within one partition. A role in the standard aws partition cannot be delegated to an account in aws-cn.

Role or resource policy

AWS offers two patterns, and they behave differently.

Assume a role Resource-based policy
Caller's permissions during the work Original permissions are given up Caller keeps working in its own account
Suits Working across many resources in the other account Moving data between accounts, such as copying objects

The first row has the largest practical effect. A role session cannot simultaneously read a bucket in the source account and write to one in the destination unless the role itself has both.

The evaluation order

AWS denies every request by default, except for the root user, then works through the following:

  1. Deny check. Any explicit deny in a service control policy, resource control policy, resource-based policy, identity-based policy, boundary or session policy is final.
  2. Resource control policies (RCPs), which limit resources in the account.
  3. Service control policies (SCPs), which limit principals in the account.
  4. Resource-based policies.
  5. Identity-based policies.
  6. Permission boundaries.
  7. Session policies.

SCPs and RCPs never grant anything. SCPs do not affect users or roles in the management account or any service-linked role. RCPs do not affect resources in the management account, do not restrict service-linked roles and cover only a listed subset of services. AWS Security Token Service is on that list.

Where a permission boundary fits

A permission boundary is a managed policy setting the maximum an IAM user or role can hold. It grants nothing. Effective permissions are the intersection of the boundary and the identity-based policies, and an explicit deny in either wins.

In a cross-account call, a boundary on the calling principal narrows what that principal's identity policy can allow. Since the trusted side must return Allow, a boundary that omits the action defeats a perfectly correct bucket policy. That is an inference from the two rules above, and the denial text confirms it when it says no permissions boundary allows.

Same-account behaviour depends on what the resource policy names. AWS separates three cases. A grant to an IAM user ARN is not limited by an implicit deny in a boundary. A grant to an IAM role ARN is limited by one, so a boundary can be the cause when a bucket policy names the role. A grant to a role session ARN goes directly to the session and is not limited by an implicit deny in a boundary or session policy. An explicit deny in the boundary still applies.

Warning: A resource policy using NotPrincipal with Deny always denies any principal with a boundary attached. AWS recommends ArnNotEquals on aws:PrincipalArn instead.

Boundaries can also limit what a delegated administrator hands out. A policy can permit iam:CreateUser only when the iam:PermissionsBoundary condition matches a named boundary, and separately deny editing that boundary policy or removing it from a user.

Writing the trust policy

This example, written for this article, is the trusting side. Account 222222222222 lets one role in 111111111111 assume prod-read:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::111111111111:role/ci-deployer" },
    "Action": "sts:AssumeRole"
  }]
}

The trusted side needs its own statement on the calling role:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "sts:AssumeRole",
    "Resource": "arn:aws:iam::222222222222:role/prod-read"
  }]
}

Name the role ARN in Resource. With "Resource": "*", the user can assume any role in any account that trusts their account.

Rules for the Principal element, all from the AWS principal reference:

  • arn:aws:iam::111111111111:root and the bare account ID behave the same. Both delegate to the account, and the ARN does not limit access to the root user.
  • A group cannot be a principal, and a wildcard cannot match part of a principal ARN.
  • Several values in an array mean any one of them.
  • A specific user or role ARN is converted to a unique principal ID when saved.

That last rule causes a recognisable fault. If the named role is deleted and recreated with the same name, the trust no longer works, and the policy shows an ID string where the ARN was. The fix is to edit the correct ARN back in. A trust that names the account and restricts by aws:PrincipalArn in a Condition is not converted, so it survives recreation. That has a cost. AWS says the conversion "helps mitigate the risk of someone escalating their privileges by removing and recreating the role". Our reading is that an ARN-string condition trusts whoever next creates a role of that name, so the protection then rests on who in that account can create roles.

Third parties and external IDs

An external ID addresses the confused deputy problem, where a service acting for many customers is tricked into using one customer's access for another. AWS is specific about its use:

The third party should test assumption without the ID as well as with it, and refuse to store a role ARN that works without it.

Assuming the role

aws sts assume-role \
  --role-arn arn:aws:iam::222222222222:role/prod-read \
  --role-session-name ci-run-42 \
  --duration-seconds 3600

Optional flags matter when the trust policy demands them: --external-id, --serial-number with --token-code for MFA, and --source-identity. The AWS CLI reference gives these limits:

Setting Limit
--duration-seconds 900 up to the role's maximum session setting, which is 1 to 12 hours
Above the role's maximum The call fails
Inline plus managed session policies One inline, up to 10 managed, 2,048 plaintext characters combined. A packed binary form has a separate limit, and a request can fail on it even when the plaintext fits

The session name is visible to the account owning the role and appears in its CloudTrail logs, so do not put anything sensitive in it.

Role chaining caps the session at one hour. That applies to the console, CLI and API, but not to the first assumption from user credentials or to applications on EC2 using instance profiles. A chained call asking for more than 3,600 seconds fails.

Console switching is a special case: it always authorises with your original credentials, even when you are already in a role.

Debugging a denial, in order

Work read-only and change nothing until you know which policy is responsible.

1. Confirm who is calling

aws sts get-caller-identity

It needs no permissions and works even under an explicit deny. It returns UserId, Account and Arn. A wrong profile or an unexpected role shows up here before any policy needs reading.

2. Read the phrase in the denial

The standard format is User user is not authorized to perform action on resource because context. The context names a policy type.

Phrase Check first
with an explicit deny in a service control policy SCPs on the account and its parents, using the ARN if shown
because no service control policy allows A missing Allow in the SCPs
with an explicit deny in a resource control policy RCPs covering the resource's account
because no role trust policy allows the sts:AssumeRole action The trust policy in the target account
with an explicit deny in the role trust policy A Deny statement in that trust policy
because no resource-based policy allows The bucket, key or secret policy
because no permissions boundary allows The boundary on the principal
because no session policy allows Policies passed at assumption
because no identity-based policy allows Policies on the caller
no VPC endpoint policy allows or explicit deny in a VPC endpoint policy The endpoint policy on the network path

An ARN is absent for inline policies. More importantly, when several policy types deny a request, AWS names only one of them. When several policies of the same type deny it, the message does not say how many were evaluated. Fixing the named policy can simply reveal the next denial. Some services do not use this message format at all.

3. Walk the assume-role checklist

If sts:AssumeRole failed, AWS's list covers these points:

  • The role name is exact, since role names are case sensitive.
  • The caller's policy allows sts:AssumeRole on that role's ARN.
  • Every trust condition is met, including external ID, source IP, expiry date, principal tags and source identity.
  • The caller's account or ARN is in Principal.

A role assuming itself needs its own ARN or the account ARN as a principal in the trust policy. AWS changed this on 21 September 2022, and says it is rarely necessary.

4. If assumption worked, check the resource

Once the role is assumed, the session uses the role's identity policies, any boundary on the role, and any session policy. SCPs on the target account also apply to it as a principal of that account, and RCPs on the account that owns the resource apply to the request. This summarises the evaluation rules above.

KMS deserves its own check. Cross-account use needs both the key policy and an IAM policy in the external account, and neither alone is enough. It is also effective only for cryptographic operations, CreateGrant, DescribeKey, GetKeyRotationStatus, GetPublicKey, ListGrants, RetireGrant and RevokeGrant. A kms:ScheduleKeyDeletion grant to another account has no effect, however it is written.

S3 follows the same two-sided rule. A bucket policy naming a user in another account still needs that user's own account to permit the action.

5. Simulate, knowing what the simulator omits

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111111111111:user/policy-test-user \
  --action-names s3:GetObject \
  --resource-arns arn:aws:s3:::example-bucket/report.csv \
  --resource-policy "$(cat bucket-policy.json)" \
  --resource-owner 222222222222

The CLI reference notes that the simulator does not fetch resource policies, so you supply one, and only one. The same page says that policy is evaluated "for IAM users only", while its --caller-arn text allows a user, group or role. The example uses an existing IAM user to stay within the documented resource-policy scope. It does not validate a role session; do not create new long-lived credentials simply for this test. --resource-owner is required when you supply a resource policy and the resource belongs to a different account from the simulated caller.

For cross-account simulations, EvalDecisionDetails shows the decision by policy type, which is the most useful field in the output. Read MatchedStatements and MissingContextValues as well, because the second lists condition keys the simulation needed but was not given.

The IAM guide lists the gaps. The simulator sends no real request and does not support RCPs. Results can differ from live behaviour for VPC endpoint policies, role chaining and multiple resource policies on one resource. An Allow here is evidence, not proof.

6. Check CloudTrail, in the right account

CloudTrail logs authenticated IAM and STS requests, but does not log denied STS requests in the target account for cross-account role assumptions. Finding nothing in the role's account therefore proves nothing about whether an attempt was made. Start in the caller's account.

A denial from a VPC endpoint policy may also go unlogged when the endpoint's owner differs from the calling account or the target role's account. By contrast, cross-account KMS operations are logged in both accounts.

7. Decode the message if one was returned

Only some operations return an encoded authorisation failure.

aws sts decode-authorization-message \
  --encoded-message "$ENCODED" --query DecodedMessage --output text

The caller needs sts:DecodeAuthorizationMessage. The decoded output shows whether the deny was explicit, the principal, the action, the resource and the condition key values, which is often enough to spot a case mismatch or an unexpected value.

What to choose and why

These are editorial recommendations.

  • Use roles by default, with a trust policy naming specific role ARNs where the set of callers is stable. Prefer role principals to role session principals, as AWS recommends.
  • Use a resource policy when one identity must work in both accounts in the same call, such as copying objects.
  • Name the account and condition on aws:PrincipalArn when the calling role is regularly recreated by automation, and control who in that account can create roles.
  • For organisation-wide trust, aws:PrincipalOrgID avoids listing account IDs and follows membership automatically. AWS warns that wildcards have no valid use in this and similar sensitive keys.
  • Use an external ID only for third parties serving several customers. It adds nothing between accounts you control, and it removes console switching.
  • Debug in the order above. Checking identity first is cheap, and checking the wrong policy is not.

Risks and limits

Warning: Do not loosen a policy to test a theory, such as temporarily adding "*". A "Principal": "*" statement is open to everyone unless a condition limits it, and such a change is easily forgotten. Test with the simulator or a separate, restricted role.

Warning: Attaching or editing an SCP or RCP takes effect immediately across the attached account or all accounts under the attached root or OU. A denial caused by one should be fixed with the change reviewed for that whole scope, not just for the failing call.

Other limits to keep in mind:

Sources

Opened 10 November 2026. Sources behind the corrections reopened 10 November 2026.