Initializing Enclave...

Fixing AWS Resource-Based Policy Principal Account ID Mismatch in Multi-Account Setups

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

TL;DR

  • What broke: A resource-based policy (S3, KMS, SQS, SNS, etc.) has a Principal element referencing the wrong AWS account ID — either a typo, a stale account from a previous org structure, or a copy-paste from a single-account template.
  • How to fix it: Locate the exact Principal ARN(s) in the policy, replace the incorrect 12-digit account ID with the verified trusted account ID, and re-evaluate using IAM Policy Simulator or aws iam simulate-custom-policy.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your policy JSON, and the engine will diff the Principal ARNs against your declared trusted account IDs without sending your ARNs to any external server.

The Incident (What Does the Error Mean?)

This is the raw error you'll see in CloudTrail or when an application hits the resource:

An error occurred (AccessDenied) when calling the AssumeRole operation:
  User: arn:aws:iam::111122223333:role/cross-account-deploy-role
  is not authorized to perform: sts:AssumeRole
  on resource: arn:aws:iam::444455556666:role/target-role

Or on a KMS decrypt call:

AccessDeniedException: The ciphertext refers to a customer master key
that does not exist, does not exist in this region, or you are not
allowed to access: arn:aws:kms:us-east-1:999988887777:key/mrk-abc123

Or directly from S3:

{
  "Code": "AccessDenied",
  "Message": "Access Denied"
}

Immediate consequence: The calling principal — a Lambda, an EC2 instance role, a CI/CD pipeline — gets a hard AccessDenied. In a deployment pipeline this kills the release. In a data pipeline it silently drops writes. In a KMS key policy it means encrypted data is permanently inaccessible until the policy is corrected and propagated.

The root cause is always the same: the Principal block in the resource-based policy contains an account ID that does not match the actual AWS account ID of the entity trying to access the resource.

// Broken KMS Key Policy — wrong account in Principal
{
  "Sid": "AllowCrossAccountDecrypt",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::111122220000:role/data-pipeline-role"
  },
  "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
  "Resource": "*"
}
// Actual caller account ID is 111122223333 — last four digits differ

AWS IAM performs exact string matching on account IDs. There is no fuzzy match, no inheritance, no fallback. One wrong digit = full denial.


The Attack Vector / Blast Radius

This misconfig cuts both ways — it creates two distinct risk surfaces simultaneously.

Surface 1 — Availability failure (the obvious one): Legitimate workloads in Account 111122223333 are locked out of the resource. Every service that depends on that KMS key, S3 bucket, or SQS queue fails with AccessDenied. In microservice architectures with shared KMS keys, a single bad key policy can cascade into 10+ service failures within minutes of a deployment.

Surface 2 — Unintended trust grant (the dangerous one): If the wrong account ID in the Principal (111122220000) belongs to another real AWS account — perhaps a decommissioned account, an acquired company's account, or a vendor account — then that external account now has explicit Allow on your resource. The IAM evaluation logic is:

  1. Resource-based policy grants Allow to account 111122220000.
  2. Account 111122220000 controls its own IAM principals.
  3. Any IAM entity in 111122220000 that has identity-based permissions to call the action gets access.

This is a data exfiltration vector. An attacker who controls or compromises account 111122220000 can now decrypt your KMS-encrypted data, read your S3 objects, or consume your SQS messages — no exploit required, the policy hands them the key.

Blast radius by service:

Service Misconfigured Principal Risk
KMS Key Policy Encrypted data accessible to wrong account; key deletion possible if kms:ScheduleKeyDeletion is in scope
S3 Bucket Policy Full object read/write/delete to wrong account
SQS Queue Policy Message consumption or injection from wrong account
SNS Topic Policy Subscription creation; data fan-out to attacker-controlled endpoints
Secrets Manager Secret value readable cross-account
ECR Repository Container image pull; supply chain compromise vector

How to Fix It (The Solution)

Step 1 — Identify the correct account ID

Never trust memory or documentation. Pull it live:

# In the TRUSTED (calling) account
aws sts get-caller-identity --query Account --output text
# Returns: 111122223333

Step 2 — Locate all affected policies

# Find all S3 bucket policies referencing a specific (wrong) account
aws s3api list-buckets --query 'Buckets[].Name' --output text | \
  tr '\t' '\n' | \
  xargs -I{} aws s3api get-bucket-policy --bucket {} 2>/dev/null | \
  grep -n "111122220000"

# Find KMS key policies
aws kms list-keys --query 'Keys[].KeyId' --output text | \
  tr '\t' '\n' | \
  xargs -I{} aws kms get-key-policy --key-id {} --policy-name default 2>/dev/null | \
  grep -n "111122220000"

Basic Fix — Correct the account ID in the Principal

# KMS Key Policy
 {
   "Sid": "AllowCrossAccountDecrypt",
   "Effect": "Allow",
   "Principal": {
-    "AWS": "arn:aws:iam::111122220000:role/data-pipeline-role"
+    "AWS": "arn:aws:iam::111122223333:role/data-pipeline-role"
   },
   "Action": [
     "kms:Decrypt",
     "kms:GenerateDataKey"
   ],
   "Resource": "*"
 }
# S3 Bucket Policy
 {
   "Sid": "CrossAccountReadAccess",
   "Effect": "Allow",
   "Principal": {
-    "AWS": "arn:aws:iam::111122220000:root"
+    "AWS": "arn:aws:iam::111122223333:root"
   },
   "Action": "s3:GetObject",
   "Resource": "arn:aws:s3:::my-data-bucket/*"
 }

Enterprise Best Practice — Condition keys + Org-level guardrails

Hardcoding account IDs is fragile. Use aws:PrincipalOrgID or aws:PrincipalAccount condition keys so the policy is account-ID-agnostic within your AWS Organization:

# KMS Key Policy — Org-aware, no hardcoded account ID
 {
   "Sid": "AllowOrgDecrypt",
   "Effect": "Allow",
-  "Principal": {
-    "AWS": "arn:aws:iam::111122220000:role/data-pipeline-role"
-  },
+  "Principal": "*",
   "Action": [
     "kms:Decrypt",
     "kms:GenerateDataKey"
   ],
   "Resource": "*",
+  "Condition": {
+    "StringEquals": {
+      "aws:PrincipalOrgID": "o-exampleorgid11"
+    },
+    "ArnLike": {
+      "aws:PrincipalArn": "arn:aws:iam::*:role/data-pipeline-role"
+    }
+  }
 }
# S3 Bucket Policy — Restrict to specific accounts without hardcoding role ARN
 {
   "Sid": "CrossAccountReadAccess",
   "Effect": "Allow",
-  "Principal": {
-    "AWS": "arn:aws:iam::111122220000:root"
-  },
+  "Principal": "*",
   "Action": "s3:GetObject",
   "Resource": "arn:aws:s3:::my-data-bucket/*",
+  "Condition": {
+    "StringEquals": {
+      "aws:PrincipalAccount": "111122223333",
+      "aws:PrincipalOrgID": "o-exampleorgid11"
+    }
+  }
 }

Why Principal: "*" with Conditions is safer than a specific ARN:

  • Survives role renames and account restructuring.
  • aws:PrincipalOrgID ensures only accounts inside your AWS Organization can satisfy the condition — external accounts are blocked even if they guess the ARN.
  • aws:PrincipalAccount as a condition key is evaluated server-side and cannot be spoofed.

Step 3 — Validate with IAM Policy Simulator

aws iam simulate-custom-policy \
  --policy-input-list file://kms-key-policy.json \
  --action-names kms:Decrypt \
  --resource-arns "arn:aws:kms:us-east-1:444455556666:key/mrk-abc123" \
  --caller-arn "arn:aws:iam::111122223333:role/data-pipeline-role"

Expected output: "EvalDecision": "allowed" — anything else means the fix is incomplete.


💡 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

This class of error is 100% preventable at the PR stage. Wire in all three layers.

Layer 1 — Checkov (static policy scan)

# .checkov.yaml
checks:
  - CKV_AWS_283   # Ensure KMS key policy does not allow wildcard Principal without conditions
  - CKV2_AWS_62   # S3 bucket policy — no cross-account wildcard
  - CKV_AWS_111   # IAM policy — no wildcard resource with sensitive actions
# Run in CI before terraform apply
checkov -d ./terraform --framework terraform --check CKV_AWS_283,CKV2_AWS_62

Layer 2 — OPA / Conftest policy (catches account ID mismatches specifically)

# policies/kms_principal_account.rego
package aws.kms.keypolicy

import future.keywords.in

allowed_accounts := {"111122223333", "444455556666"}  # trusted account IDs

deny[msg] {
  statement := input.Statement[_]
  statement.Effect == "Allow"
  principal_arn := statement.Principal.AWS
  is_string(principal_arn)
  account_id := regex.find_n(`\d{12}`, principal_arn, 1)[0]
  not account_id in allowed_accounts
  msg := sprintf(
    "KMS policy Principal references untrusted account ID '%v' in ARN '%v'",
    [account_id, principal_arn]
  )
}
# In CI pipeline
conftest test kms-key-policy.json --policy policies/

Layer 3 — Terraform variable enforcement (prevent hardcoding)

# variables.tf — enforce account IDs come from data sources, never literals
variable "trusted_account_ids" {
  type        = list(string)
  description = "Verified AWS account IDs allowed cross-account access"
  validation {
    condition     = alltrue([for id in var.trusted_account_ids : can(regex("^\\d{12}$", id))])
    error_message = "All account IDs must be exactly 12 digits."
  }
}

# Fetch at plan time — never hardcode
data "aws_caller_identity" "trusted" {
  provider = aws.trusted_account
}

# Use in policy
locals {
  trusted_account_id = data.aws_caller_identity.trusted.account_id
}

Layer 4 — AWS Config Rule (runtime drift detection)

# Deploy managed rule to catch cross-account policy drift post-deploy
aws configservice put-config-rule --config-rule '{
  "ConfigRuleName": "kms-key-policy-no-untrusted-principals",
  "Source": {
    "Owner": "AWS",
    "SourceIdentifier": "KMS_CMK_NOT_SCHEDULED_FOR_DELETION"
  }
}'

# For custom account ID validation, use a Lambda-backed Config rule
# triggered on kms:PutKeyPolicy CloudTrail events

The non-negotiable CI/CD gate: Block terraform apply if conftest or checkov exits non-zero. No exceptions for "we'll fix it post-deploy." A KMS key policy with a wrong Principal pushed to production means encrypted data is either locked or exposed — neither is recoverable without downtime.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →