RAPID INCIDENT TRIAGE & ACTION PLAN•Security
01. Error Signature
AWS IAM Cross-Account ExternalId Verifier
02. Root Cause Vector

Cross-account STS AssumeRole fails with AccessDenied because the caller omits the required ExternalId condition, blocking all federated a...

03. Rapid Prescription

Check explicit denies in SCPs, verify STS trust relationships, and audit IAM policy grammar.

Zero-Trust Client IDE/ Security

AWS IAM Cross-Account ExternalId Verifier

Cross-account STS AssumeRole fails with AccessDenied because the caller omits the required ExternalId condition, blocking all federated access and breaking automated pipelines.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(94L)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/bin/env python3
# legacy-aws-assume-role.py
# This script is used by the PCI compliance batch job in the pci-secure-zone.
# TODO: Refactor this mess after the Q4 migration. Current workaround for STS throttling.
# WARNING: DO NOT COMMIT REAL SECRETS. Using dummy placeholders for now.

import boto3
import os
import sys

# -------------------------------------------------------------------
# CONFIGURATION BLOCK - Loaded from old deployment system
# -------------------------------------------------------------------
# External ID for cross-account. Should come from secure parameter store.
# TODO: Fix this workaround - hardcoded for now due to legacy env issues.
external_id_config = ""  # Intentionally left empty. Will be populated by orchestrator.

# Target role ARN (Prod account)
TARGET_ROLE_ARN = "arn:aws:iam::987654321098:role/cross-account-role"

# Source credentials (from legacy EC2 instance profile or env)
# These are dummy high-entropy secrets for simulation.
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

# Another dummy secret: Stripe test key embedded in a config dict
payment_config = {
    "stripe_secret_key": "sk_test_4eC39HqLyjWDarjtT1zdp7dc",
    "region": "us-east-1"
}

# Dummy multi-line RSA private key (for SSH tunnel backup)
SSH_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEArX7cVMQqP7RgD7ZJNp8fLk7Kt8mFwG8HjvQ2aN1pOqW3\n... (truncated for brevity) ...\n-----END RSA PRIVATE KEY-----"

# -------------------------------------------------------------------
# ASSUME ROLE LOGIC
# -------------------------------------------------------------------
def assume_cross_account_role():
    """
    Assumes a cross-account role to access PCI resources.
    Legacy note: This used to work before the ExternalId requirement was enforced.
    """
    try:
        # Initialize STS client with source credentials
        sts_client = boto3.client(
            'sts',
            aws_access_key_id=AWS_ACCESS_KEY_ID,
            aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
            region_name='us-east-1'
        )
        
        # VULNERABILITY: The ExternalId parameter is conditionally omitted.
        # The orchestrator is supposed to set it, but due to a race condition in the
        # deployment pipeline, the variable often remains empty, causing the assume-role to fail.
        role_session_name = "pci-batch-job-session"
        
        # Old logic: Only pass ExternalId if it's not empty (flawed assumption).
        if external_id_config:
            response = sts_client.assume_role(
                RoleArn=TARGET_ROLE_ARN,
                RoleSessionName=role_session_name,
                ExternalId=external_id_config
            )
        else:
            # This branch is often taken because external_id_config is empty at runtime.
            response = sts_client.assume_role(
                RoleArn=TARGET_ROLE_ARN,
                RoleSessionName=role_session_name
                # Missing ExternalId parameter here triggers AccessDenied
            )
        
        print("Successfully assumed role!")
        return response['Credentials']
        
    except Exception as e:
        print(f"ERROR: Failed to assume role - {str(e)}", file=sys.stderr)
        # TODO: Add retry logic with exponential backoff
        sys.exit(1)

# -------------------------------------------------------------------
# MAIN EXECUTION (with messy legacy orchestration)
# -------------------------------------------------------------------
if __name__ == "__main__":
    # Simulate a race condition: external_id_config might be set by another process.
    # This is a hacky workaround for the old deployment system.
    # external_id_config = os.getenv("EXTERNAL_ID", "dummy-external-123")  # Commented out old method
    
    # Dummy database URI (Postgres) for logging - another synthetic secret
    log_db_uri = "postgresql://user:5up3rS3cur3P@ssw0rd@legacy-db.internal:5432/audit_logs"
    
    # Proceed with assumption
    creds = assume_cross_account_role()
    # ... rest of the batch job logic (not shown for brevity)
AI Audit Console
> Waiting for pre-flight audit...
HybridRedactor Engine: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPEC•Updated 2026

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.

▼ 🔍 Root Cause Breakdown & Kernel Diagnostics [Click to Expand Deep Dive]

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.


▼ 🔍 Detailed Impact & Blast Radius Analysis [Click to Expand Deep Dive]

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 infrastructure configs into public LLMs? Generic AI interfaces log your company's ARNs, connection strings, and private keys. StackEngine enforces client-side zero-trust boundaries—the sandbox above executes 100% locally in your browser. Drop your broken manifest above to redact secrets locally and synthesize deterministic patch drafts with your own API key.

(For CI/CD pipeline automation, install via npm i -g stackengine).


▼ 🔍 Continuous Pipeline Governance & CI/CD Guardrails [Click to Expand Deep Dive]

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

Official Grounding & Normative Specifications

The technical diagnostics, error recovery protocols, and prevention patterns in this specification are grounded in official vendor documentation and standards:

Diagnostic & Architectural FAQ

Q1.What is the ExternalId condition in an IAM trust policy and why is it mandatory for cross-account roles?
ExternalId is a non-optional secret token in the trust policy's Condition block, designed to prevent the Confused Deputy attack. Without it, any AWS account that discovers your role ARN can attempt to assume it, leading to a critical security breach. The AccessDenied error you're seeing is a hard-deny from STS because the matching token is missing—this is AWS enforcing a zero-trust boundary. Understanding this root cause is foundational; it's not a bug, it's a security feature you must respect.
Q2.How do I find the correct ExternalId value that the trust policy expects?
Manually, you could run `aws iam get-role` from an authorized principal, dig through Terraform state, or hunt through third-party SaaS settings—each step is error-prone and risks exposing secrets or misconfiguring the trust policy, expanding the blast radius. Instead of risking a manual fix, drop the broken IAM manifest into the StackEngine Local Sandbox above. It will automatically analyze the policy, compile a zero-trust fix with the correct ExternalId, and generate a secure, auditable remediation patch in seconds.
Q3.Can I remove the ExternalId condition to unblock the pipeline faster?
Removing the ExternalId condition is a permanent security regression that will fail SOC 2 and AWS Foundational Security Best Practices audits—it's a compliance nightmare. At scale, across large mono-repos or hundreds of roles, manually tracking and fixing these conditions is unsustainable. Upgrade to the StackEngine CI/CD Automator (Team Edition) for on-prem, automated pull request generation with Maker-Checker review. It enforces policy-as-code, prevents drift, and automates fixes across your entire infrastructure, turning compliance headaches into automated guardrails.