Cloud Infrastructure · Identity and Access
Trust Policies, External IDs, and the Confused Deputy
Why a trust policy naming a principal is not enough when that principal acts for many customers, and how a shared secret in the assumption request closes the gap.
A trust policy is a resource policy on a role. It names who may assume the role and under what conditions, and it is written by the account that owns the role. The intuition it invites is that naming a principal is the same as authorizing that principal. For a principal that is a single human or a single service in a single account, the intuition holds closely enough. For a principal that is a vendor’s service acting on behalf of many customers, it fails, and the failure has a name from 1988.
The deputy problem stated precisely
Norm Hardy’s confused deputy is a program that has authority and is asked by a less privileged party to use that authority. The program checks whether the requester is allowed to make the request, but it checks against its own identity rather than against the requester’s intent, and so it lends its authority to whoever asks. The canonical form: a compiler with write access to a billing file is asked to compile a program whose output path is the billing file, and it writes the file because it has the permission, not because the requester does.
The cloud form is the same shape with different nouns. A monitoring vendor operates a collector in its own account. That collector holds a role in each customer’s account, with read access to metrics. Each customer, during onboarding, creates a role and writes a trust policy that allows the vendor’s account to assume it. The vendor’s collector receives a request from customer A to poll customer A’s account, assumes the role in A’s account, and returns the data.
Now customer B, who is also a customer of the same vendor, discovers the vendor’s account identifier. B has no role in A’s account and no credentials in A’s account, but B can ask the vendor’s collector to poll an arbitrary account. If the collector assumes whatever role its credentials permit, and the trust policy in A’s account names only the vendor account, then the collector will happily assume A’s role and hand A’s metrics to B. The deputy is the vendor. The authority it misuses is the trust placed in it by A. The check that failed is that A trusted an account, and an account is not a customer.
Why naming an account is too coarse
The trust policy’s Principal field accepts an account identifier, an ARN, or a service principal. When it accepts an account identifier, the grant covers every principal in that account that also holds an identity policy allowing the assumption. There is no way for the trusting account to distinguish among the vendor’s customers, because from the trust policy’s position there is exactly one caller: the vendor’s account.
The alternative would be for each customer to have a distinct principal in the vendor’s account, one per tenant. Some architectures do that, creating a per-tenant role in the vendor account. It works, and it multiplies the number of roles and trust policies by the number of tenants, and it makes onboarding a provisioning operation on both sides rather than a document edit on one side. For a vendor with thousands of small customers, that is not a viable shape.
The second alternative is to keep one principal and add a condition that only the correct tenant can satisfy. That is the external ID, and it is the design that won.
The external ID as a capability token
An external ID is a string chosen by the trusting account, stored in the trust policy as a condition on the sts:ExternalId request parameter, and given to the vendor out of band. When the vendor’s collector assumes the role on behalf of customer A, it must pass A’s external ID in the request. A request that passes B’s external ID, or none, does not match the condition and is denied.
The property that makes this work is unguessability. If the external ID is a long random string, then knowing the vendor’s account identifier is not enough to assume any customer’s role, because the identifier does not reveal any customer’s external ID. The vendor holds the mapping from tenant to external ID and refuses to assume a role for a tenant it was not asked about. The check moves from “is the caller the vendor” to “is the caller the vendor, acting for this specific tenant”, and the second check is the one that was needed.
Two properties of the mechanism are easy to get wrong. The external ID is not a secret in the cryptographic sense: it travels in the request, it appears in the vendor’s configuration, and it may appear in logs. It is a capability, and its security depends on it being unguessable and on the vendor not accepting it from the tenant’s own request. If the vendor’s onboarding flow lets a tenant supply an arbitrary external ID for their own account, a tenant can choose an ID that collides with another tenant’s, and the confusion returns by a different route. The trusting account must generate the ID, not the vendor and not the tenant.
The second property is that the condition must use StringEquals rather than StringLike. A condition written with StringLike and a value containing a wildcard would accept any external ID matching the pattern, and a tenant who learns the pattern can construct a matching value. Equality against a full random string has no such slack.
The trust policy as a document with two jobs
A trust policy does two things at once, and the two are often conflated. It authenticates the caller, by naming the principal that may assume; and it authorizes the assumption, by matching conditions against the request. Conditions can test the external ID, the source account, the source ARN, whether MFA was used, the source IP range, and the VPC endpoint the request traversed.
The distinction matters when a condition is used as authentication. A condition on aws:SourceAccount is an authorization constraint, not an authentication one: it says the request came from an account, and any principal in that account that can call the token service satisfies it. A condition on aws:PrincipalArn is closer to authentication, because it names the specific principal ARN that signed the request. Neither substitutes for the external ID when the named principal is a multi-tenant service.
Conditions also compose with the deny side. An explicit deny in a permissions boundary or an organization policy terminates evaluation before the trust policy is consulted, which is the interaction described in the evaluation order. A role whose trust policy allows a vendor can still be unusable if the account’s organization policy forbids the token action.
Both accounts must agree, and the messages differ
A cross-account assumption is evaluated in two places. The caller’s account must allow the principal to call the token service against the target role ARN, and the target account’s trust policy must allow the caller. Either can refuse, and the error text distinguishes them: a refusal from the caller’s side names the caller’s identity, and a refusal from the target side names the trust policy.
The asymmetry in who can fix what is the practically important part. The caller’s account owner can fix a missing identity permission. Only the target account owner can edit the trust policy, and the target owner may be a different organization entirely. A cross-account integration that fails therefore has a resolution path that crosses an organizational boundary, and the external ID is the artifact that has to be exchanged across it.
The same asymmetry applies to the resource-side variant of the pattern: a bucket policy or a key policy naming a foreign principal grants access without any identity policy on the caller’s side, and it is not capped by the caller’s permissions boundary. That channel is a common way for a boundary to be bypassed unintentionally, and it is why the boundary is described as a cap on one channel rather than a global ceiling.
What the mechanism still does not prevent
An external ID prevents a vendor from being tricked into assuming the wrong tenant’s role. It does not prevent the vendor from being malicious, from having its own credentials stolen, or from being compelled to hand over data. It also does not prevent a tenant from deliberately configuring their own account to trust a role the vendor controls in a way that exposes the tenant’s data to the vendor. Trust is asymmetric: the external ID protects the trusting account from other tenants of the same vendor, and nothing in the mechanism protects the trusting account from the vendor.
The residual risk is bounded by the role’s permissions, which is why the role handed to a third party should be narrow in actions and in resources, and why the trust policy should constrain the conditions as tightly as the integration permits. A vendor role with read access to one prefix of one bucket and a condition on the external ID has a small blast radius. A vendor role with * and no condition has the same blast radius as handing over a root key, and the trust policy will look unremarkable in review because it names a plausible principal.
For assumption flows that a workload drives itself, the same trust question appears in a different form: the instance metadata service will hand credentials to any process that can reach it, so the network position becomes the identity. That failure mode is covered in the instance credential chain, and it is the confused deputy again, with a link-local address as the deputy.
References
- The Confused Deputy (or why capabilities might have been invented) — Norm Hardy, ACM SIGOPS Operating Systems Review, 1988
- IAM roles terms and concepts — AWS Identity and Access Management User Guide
- Third-party access: using an external ID — AWS Identity and Access Management User Guide
- Cross-account policy evaluation logic — AWS Identity and Access Management User Guide