Initializing Enclave...

Fixing AWS IAM Cross-Account AssumeRole AccessDenied: Missing ExternalId Condition Key

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 5–15 mins


TL;DR

  • What broke: The IAM trust policy on arn:aws:iam::987654321098:role/cross-account-role contains a sts:ExternalId condition key. The caller (arn:aws:iam::123456789012:user/developer) invoked sts:AssumeRole without supplying --external-id, causing an immediate hard deny.
  • How to fix it: Add --external-id <value> to the CLI call or SDK session config, matching the exact string in the role's trust policy Condition block.
  • Shortcut: Use our Client-Side Sandbox above to auto-refactor this — paste your trust policy and assume-role command and get the corrected output without leaking ARNs to a third-party server.

The Incident (What Does the Error Mean?)

Raw error:

An error occurred (AccessDenied) when calling the AssumeRole operation:
User: arn:aws:iam::123456789012:user/developer
is not authorized to perform: sts:AssumeRole
on resource: arn:aws:iam::987654321098:role/cross-account-role

STS evaluated the trust policy on cross-account-role in account 987654321098. The policy contains a StringEquals condition on sts:ExternalId. Because the incoming request carried no ExternalId token, the condition evaluated to false, and IAM issued a hard deny — not a soft implicit deny. There is no fallback. Every downstream call that depends on credentials vended by this role is now returning ExpiredTokenException or InvalidClientTokenId because no session was ever established.


The Attack Vector / Blast Radius

ExternalId exists specifically to prevent the Confused Deputy Problem. Without it, any AWS principal that knows your role ARN can attempt to assume it from their own account. The sequence:

  1. Attacker discovers arn:aws:iam::987654321098:role/cross-account-role via exposed CloudFormation outputs, public S3 policy, or misconfigured resource tag.
  2. Attacker controls an AWS account and calls sts:AssumeRole targeting your role ARN.
  3. If no ExternalId condition exists — or if it was accidentally removed — STS grants the session. The attacker now holds temporary credentials scoped to whatever that role permits in account 987654321098.
  4. Blast radius depends on the role's permission boundary. If the role carries s3:*, ec2:*, or worse iam:PassRole, you have a full account-level lateral movement path.

The current error is the trust policy working correctly. The operational failure is that your legitimate caller was never updated to supply the token.


How to Fix It (The Solution)

Basic Fix — CLI

# Failing call
- aws sts assume-role \
-   --role-arn arn:aws:iam::987654321098:role/cross-account-role \
-   --role-session-name dev-session

# Fixed call
+ aws sts assume-role \
+   --role-arn arn:aws:iam::987654321098:role/cross-account-role \
+   --role-session-name dev-session \
+   --external-id "YOUR_EXACT_EXTERNAL_ID_STRING"

The --external-id value must be a byte-for-byte match to the string in the trust policy. Case-sensitive. No trimming.


Enterprise Best Practice — Trust Policy (Terraform)

# trust_policy.json — the role in account 987654321098
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": {
          "AWS": "arn:aws:iam::123456789012:root"
        },
        "Action": "sts:AssumeRole",
        "Condition": {
-         "StringEquals": {}
+         "StringEquals": {
+           "sts:ExternalId": "acme-prod-pipeline-x7f2"
+         }
        }
      }
    ]
  }
# Terraform aws_iam_role_policy_attachment caller config
  resource "aws_iam_role" "cross_account" {
    name               = "cross-account-role"
    assume_role_policy = data.aws_iam_policy_document.trust.json
  }

  data "aws_iam_policy_document" "trust" {
    statement {
      actions = ["sts:AssumeRole"]
      principals {
        type        = "AWS"
        identifiers = ["arn:aws:iam::123456789012:root"]
      }
      condition {
        test     = "StringEquals"
        variable = "sts:ExternalId"
-       values   = []
+       values   = ["acme-prod-pipeline-x7f2"]
      }
    }
  }

Additional hardening: Pair sts:ExternalId with aws:PrincipalArn StringEquals to pin the exact IAM entity, not just the account root. Rotate the ExternalId value quarterly via Secrets Manager and update the trust policy atomically in the same pipeline stage.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. Checkov — block trust policies missing ExternalId:

# .checkov.yml
checks:
  - CKV_AWS_110  # Ensure IAM roles require ExternalId for cross-account trust

If CKV_AWS_110 doesn't cover your custom condition, write a custom check:

from checkov.common.models.enums import CheckResult
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck

class CrossAccountExternalIdCheck(BaseResourceCheck):
    def scan_resource_conf(self, conf):
        policy = conf.get("assume_role_policy", [{}])[0]
        for stmt in policy.get("Statement", []):
            principal = stmt.get("Principal", {})
            if "AWS" in principal and "123456789012" not in str(principal):
                cond = stmt.get("Condition", {})
                if "StringEquals" not in cond or "sts:ExternalId" not in cond.get("StringEquals", {}):
                    return CheckResult.FAILED
        return CheckResult.PASSED

2. OPA / Conftest — enforce at plan time:

package terraform.iam

deny[msg] {
  r := input.resource_changes[_]
  r.type == "aws_iam_role"
  stmt := r.change.after.assume_role_policy.Statement[_]
  not stmt.Condition.StringEquals["sts:ExternalId"]
  msg := sprintf("Role %v missing sts:ExternalId condition in trust policy", [r.address])
}

3. AWS Config Rule: Enable iam-role-managed-policy-check and supplement with a custom Config rule that evaluates GetRolePolicy responses for the sts:ExternalId condition on any role whose principal spans more than one account.

4. Pipeline gate: In GitHub Actions, fail the terraform plan step if external_id variable is empty:

- name: Validate ExternalId set
  run: |
    if [ -z "${{ secrets.CROSS_ACCOUNT_EXTERNAL_ID }}" ]; then
      echo "ERROR: CROSS_ACCOUNT_EXTERNAL_ID secret is not set"
      exit 1
    fi

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →