Cross-account access
Third-party role access with an external ID
A trust policy for a SaaS vendor: trust the vendor's documented role, require the external ID it issued you, and verify it from your own account.
Published · Updated .
The problem
A security scanning or cost reporting vendor asks you to create a role in 111122223333 that its platform will assume from the vendor's account, 444455556666. The vendor serves many customers from that one account. When its platform calls AssumeRole, the only thing that connects the call to you is the role ARN stored in the vendor's database for your tenant.
That is the confused deputy problem. Role ARNs are not secret: another customer of the same vendor could learn or guess yours and enter it in their own tenant. If your trust policy accepts any request from the vendor, the vendor would assume your role for that customer and show them your data. With an external ID, the vendor sends a unique per-customer value on every AssumeRole call, and your trust policy requires yours. The other customer cannot change the value sent for their tenant, so the call into your role fails.
An earlier version of this page suggested testing by calling AssumeRole without the external ID. You can't: only the vendor is trusted, so your own calls are denied whether or not the check works. Verify from what you can see instead: the stored policy, CloudTrail in your account and, optionally, a stand-in setup in sandbox accounts.
The policy
Trust policy on VendorReadOnlyAccess
IAM role trust policy. Attach to: The trust policy of the role VendorReadOnlyAccess in your account (111122223333)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TrustVendorRoleWithExternalId",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::444455556666:role/VendorScannerRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "7d1f3a9c-EXAMPLE-tenant-ext-id"
}
}
}
]
}The form generators on this site don't edit trust policy documents; copy it, then check it with IAM Access Analyzer as shown below.
Statement by statement
- TrustVendorRoleWithExternalId accepts AssumeRole only from the vendor's role and only with your external ID; StringEquals is case-sensitive. Trust the specific role ARN when the vendor documents one; an account principal, as in AWS's generic example, delegates to whoever the vendor's own policies allow. If the vendor documents only an account ID, use it and keep the condition. Never invent the value: AWS states it must be generated by the vendor, not its customers, so it is unique across them.
Before you start
- From the vendor's onboarding page or documentation: its AWS account ID, the role ARN it assumes from (if it publishes one), the external ID for your tenant, and the permissions the product needs.
- IAM permissions in 111122223333 to create the role and attach policies.
- For the CloudTrail check, access to CloudTrail Event history or to your organization's trail.
Deploy it step by step
- Create the role with the vendor's values. Put the vendor's role ARN and external ID into trust-policy.json and create the role.
Commands (replace <placeholders>) aws iam create-role --role-name VendorReadOnlyAccess --assume-role-policy-document file://trust-policy.json --max-session-duration 3600 --description "Assumed by the vendor scanner; external ID required" - Attach only the documented permissions. Prefer the vendor's least-privilege list. AWS managed SecurityAudit or ViewOnlyAccess are common, but read them first: they expose metadata about every resource. AWS notes the vendor's usage is billed to you.
Commands (replace <placeholders>) aws iam attach-role-policy --role-name VendorReadOnlyAccess --policy-arn arn:aws:iam::aws:policy/SecurityAudit - Hand over the role ARN and run the vendor's connection test. Give the vendor arn:aws:iam::111122223333:role/VendorReadOnlyAccess. AWS advises vendors to try assuming a customer's role both with and without the correct external ID, and to refuse a role that works without it. A careful vendor will tell you if your condition is missing.
Verify it without changing anything
These checks only read state, simulate, or use dry runs, so they are safe to run in any account. Exact error wording varies by service and over time.
Inspect the trust policy as IAM stored it
The check you can always run, and the one that matters most.
aws iam get-role --role-name VendorReadOnlyAccess --query Role.AssumeRolePolicyDocumentExpected: One Allow statement whose principal is the vendor's role ARN, with a StringEquals condition on sts:ExternalId holding exactly the vendor's value. An ID beginning with AROA in place of the ARN means the vendor recreated its role, and you need its new ARN. A second statement, or a principal without the condition, is a finding to fix.
Validate the policy and check for public trust
Access Analyzer checks syntax and whether anyone at all could assume the role.
aws accessanalyzer validate-policy --policy-type RESOURCE_POLICY --validate-policy-resource-type AWS::IAM::AssumeRolePolicyDocument --policy-document file://trust-policy.json
aws accessanalyzer check-no-public-access --policy-document file://trust-policy.json --resource-type AWS::IAM::AssumeRolePolicyDocumentExpected: No ERROR or SECURITY_WARNING findings from validate-policy, and result PASS from check-no-public-access. PASS rules out public access only. It cannot tell you whether the external ID is the right one.
Find the vendor's AssumeRole calls in your CloudTrail
Successful cross-account AssumeRole calls are logged in the role's account as well as the caller's: in us-east-1 for the global STS endpoint, otherwise in the Region of the endpoint used. Event history covers 90 days.
aws cloudtrail lookup-events --region us-east-1 --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole --max-results 50 --query 'Events[].CloudTrailEvent' --output textExpected: Events whose requestParameters.roleArn is your VendorReadOnlyAccess role, with a userIdentity of type AWSAccount carrying the vendor's account ID, and the session name the vendor chose. AWS's published sample events show an externalId field under requestParameters when the caller supplies one. The IAM documentation doesn't promise it, so look at a real event before you build alerts on it. Denied cross-account attempts are not logged in your account, so a blocked confused-deputy attempt never shows up here.
Check when the role was last used
Confirms the vendor's connection test reached your role.
aws iam get-role --role-name VendorReadOnlyAccess --query Role.RoleLastUsedExpected: A LastUsedDate and Region shortly after the vendor's test. The value can lag behind the actual call. An empty result means the role has not been assumed yet.
Optional: rehearse with a stand-in vendor in sandbox accounts
Copy the trust policy into a sandbox account, with a test role in a second sandbox account as principal, and call AssumeRole from that test role with the correct value, a wrong one and none.
aws sts assume-role --role-arn arn:aws:iam::<sandbox-account-id>:role/VendorAccessTest --role-session-name extid-ok --external-id <external-id>
aws sts assume-role --role-arn arn:aws:iam::<sandbox-account-id>:role/VendorAccessTest --role-session-name extid-wrong --external-id <some-other-value>
aws sts assume-role --role-arn arn:aws:iam::<sandbox-account-id>:role/VendorAccessTest --role-session-name extid-noneExpected: The first call returns credentials. The other two fail with AccessDenied: an implicit deny because the trust policy's condition does not match. The message wording varies.
Common mistakes and troubleshooting
Treating the external ID as a password
AWS does not treat it as a secret. Anyone who can read the role can see it, and it does not need rotating like a key. What protects you is that it is unique to your tenant and that the vendor controls which value goes with which customer.
Choosing the value yourself
If customers pick values, two could pick the same one, or one could copy yours, which brings the problem back. Be wary of a vendor that lets customers type any value.
Dropping the condition to make onboarding work
Removing the condition to fix a failing connection test opens the role to every tenant of that vendor. Compare the vendor's value with get-role output; a stray space or case difference is the usual cause.
Limitations and production considerations
- The external ID protects against the vendor being misled. It does not protect against the vendor being compromised. If the vendor's account is breached, the attacker can send your external ID too, so the role's permissions are your real control.
- Trusting the vendor's role ARN means the vendor recreating that role breaks the integration until you update the ARN. The failure is safe but disruptive. Agree on how the vendor will announce such changes.
- IAM Access Analyzer external access findings will list this role as accessible from 444455556666. That is intended. Archive the finding with a note rather than ignoring the analyzer.
Official AWS documentation
Account IDs, bucket and organization names are AWS documentation placeholders. Test in a non-production account before use (see the methodology).