← Back to blog
Cloud / AWSMay 12, 2026· 9 min

Cross-Account IAM Roles Done Right: ExternalId, aws:SourceArn, and the Confused Deputy

A working engineer's guide to hardening cross-account trust policies against the confused-deputy problem, with the exact conditions that separate a safe vendor integration from a tenant-isolation breach.

Cross-Account IAM Roles Done Right: ExternalId, aws:SourceArn, and the Confused Deputy

Cross-account IAM roles are the connective tissue of modern AWS. Your monitoring vendor, your CSPM, your data pipeline, and half your internal automation all reach into your account by assuming a role you created and trusting a principal in someone else's account. That trust is one JSON document deep, and getting it wrong does not throw an error or fail a deploy. It silently widens your blast radius until the day an attacker, or a careless multi-tenant vendor, walks through it. This article is about writing that document correctly.

The confused deputy, concretely

The confused deputy is a privilege-escalation pattern where a trusted intermediary is tricked into using its authority on an attacker's behalf. Picture a SaaS vendor, AcmeCSPM, that scans customer accounts. Every customer creates a role that trusts AcmeCSPM's single AWS account (their 'deputy'), and AcmeCSPM stores each customer's role ARN and calls sts:AssumeRole when it runs a scan. Now an attacker signs up for AcmeCSPM and, during onboarding, supplies your role ARN instead of their own. If your trust policy says nothing more than 'I trust the AcmeCSPM account,' AcmeCSPM's backend will happily assume your role on the attacker's request. AcmeCSPM is the confused deputy: it has legitimate authority to assume your role and was tricked into exercising it for the wrong party. Nothing it did was unauthorized by your policy, which is exactly the problem.

ExternalId: the shared secret that names the caller

The fix for third-party access is sts:ExternalId. The vendor generates a unique, unpredictable value per customer and you bake it into your trust policy as a Condition. The vendor must pass the matching ExternalId on every AssumeRole call, or STS denies it. Because the attacker cannot learn the ExternalId the vendor assigned to your tenant, they cannot make the deputy assume your role even if they know its ARN. Crucially, the customer sets the condition and the vendor supplies the value, so a malicious vendor employee cannot quietly drop the check; it lives in your account. ExternalId is for separate-organization access, not for your own accounts where you control both ends. Make it unpredictable: an ExternalId equal to your AWS account number defeats the purpose, since anyone who knows your account ID can guess it.

aws:SourceArn and aws:SourceAccount for AWS services

ExternalId does not exist when an AWS service assumes a role on your behalf. When CloudWatch, S3, CloudTrail, SNS, or Lambda calls into a role you granted to a service principal (for example lambda.amazonaws.com or s3.amazonaws.com), there is no ExternalId to pass. Here the confused deputy is the AWS service itself, which could be induced to act on a resource in another account. The defense is the aws:SourceArn and aws:SourceAccount global condition keys, which pin the trust to the specific resource and account that legitimately triggers the service. Use aws:SourceArn when you can name the exact triggering resource (a specific bucket, trail, topic, or distribution) and aws:SourceAccount as the broader fallback when the ARN is opaque or not known ahead of time. Prefer ArnLike to scope to a resource pattern, and use the IfExists variant when a service may not populate SourceArn on every call path. The principle is identical to ExternalId: do not trust the principal alone, trust the principal plus proof of which resource invoked it. The two trust policies below show each guard in place.

// 1) THIRD-PARTY VENDOR ROLE -- guard with sts:ExternalId
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAcmeCSPMWithExternalId",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::210987654321:root" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "acme-cspm-7Qd2-Kx91-Tn4v-Z0bL"
        }
      }
    }
  ]
}

// 2) AWS SERVICE ROLE -- guard with aws:SourceArn / aws:SourceAccount
// Pin the trust to ONE specific triggering resource.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowServiceForOneResourceOnly",
      "Effect": "Allow",
      "Principal": { "Service": "sns.amazonaws.com" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "123456789012"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:sns:us-east-1:123456789012:alerts-topic"
        }
      }
    }
  ]
}

Rule of thumb: if the Principal is an AWS account or IAM principal you do not own, reach for sts:ExternalId. If the Principal is an AWS service (a *.amazonaws.com service principal), reach for aws:SourceArn and aws:SourceAccount. Never ship a trust policy whose only gate is the Principal.

Session policies, AssumeRole conditions, and detection

The trust policy is only the door. What the assumed session can actually do is the intersection of the role's identity policies with any session policy passed at assume time. When you delegate, pass a tightly scoped session policy or a permissions boundary so a compromised vendor session cannot exercise the role's full grant. Other conditions worth enforcing on the AssumeRole statement: aws:PrincipalOrgID to constrain trust to your AWS Organization, sts:RoleSessionName patterns for CloudTrail attribution, aws:SourceIp or aws:VpcSourceIp where the caller's network is predictable, and DurationSeconds limits to shrink the window of a leaked session token. Tag-based access control (aws:RequestTag and aws:PrincipalTag with sts:TagSession) lets you do ABAC across the boundary instead of minting a separate role per tenant. For detection at scale, the single highest-value audit is hunting wildcards and unconditioned trust. Inventory every role's AssumeRolePolicyDocument, flag the dangerous shapes below, and back the static scan with IAM Access Analyzer external-access findings, which specifically surface roles reachable from outside your trust zone. Encode these patterns as a recurring check and gate new roles in CI before they ever reach an account. The confused-deputy problem is not exotic; it is the default outcome of a trust policy that names a principal and stops there. Add the condition that proves who is really calling, scope the session to least privilege, and the door only opens for the caller you meant.

  • Principal '*' on sts:AssumeRole with no Condition: anyone, anywhere can assume the role. Treat as critical.
  • Cross-account root Principal with no sts:ExternalId and no aws:PrincipalOrgID: the classic confused-deputy gap.
  • Service principal (lambda/s3/sns/cloudtrail.amazonaws.com) with no aws:SourceArn or aws:SourceAccount: a service-side confused deputy.
  • ExternalId equal to the account ID, sequential, or otherwise guessable: present but ineffective.
  • Overlong DurationSeconds or no session policy on a high-privilege delegated role: a blast-radius amplifier even when trust is correct.

Learn it by doing

Spin up a real AWS security lab, or explore our training tracks.

24 people viewing now