AWS Lesson 61 of 123

Building a Data Perimeter with Resource Control Policies and Declarative Policies

A data perimeter answers one brutal question: even if a valid IAM credential leaks, can an attacker move your data out of the org, or pull it in from somewhere you do not trust? Service Control Policies (SCPs) get you part of the way, but they are an identity-side control — they constrain your principals. They say nothing about a foreign or anonymous principal hitting your S3 bucket with a presigned URL, or one of your own roles writing to a bucket in someone else’s account. That gap is exactly where exfiltration lives, and it is the gap that has shown up in nearly every “leaked key → data gone” incident report of the last five years.

Resource Control Policies (RCPs), generally available since November 2024, close it: org-wide guardrails that attach to the resource side of a request and evaluate against whoever is calling — including principals from outside your organization and unauthenticated callers redeeming a presigned URL. Pair them with Declarative Policies to durably pin EC2 configuration (IMDSv2 required, no public AMIs, no public snapshots) and you have a perimeter that survives credential theft, console misconfiguration, and the intern who clicks “make public.” This guide builds all three — the identity perimeter (RCP), the resource perimeter (SCP), the network perimeter (VPC endpoints + aws:SourceVpce), and the durable EC2 baseline (declarative) — end to end, with the exact aws CLI, the JSON, the Terraform, and the CloudTrail pre-flight that keeps you from paging yourself at 2 a.m.

Everything assumes AWS Organizations with all features enabled and a real OU structure. If you are still in consolidated-billing-only mode, fix that first — RCPs, SCPs, and declarative policies all require all-features mode and none of them protect the management account. Because this is a reference you will return to mid-rollout, the policy-type matrix, the condition keys, the supported services, the error codes, and the symptom→cause→fix playbook are all laid out as scannable tables — read the prose once, then keep the tables open while you stage the waves.

What problem this solves

The pain is specific and it is expensive. You have spent years building a clean SCP layer, you feel governed, and then a CI/CD access key leaks from a build log. The key belongs to a legitimate org principal, so every identity-side control you own — every SCP — waves it through. The attacker mints a presigned S3 URL and pulls a customer dataset from a coffee-shop IP to a bucket in their account. Nothing in your guardrails fired, because nothing was watching the resource side of the request or the network it came from. The data is gone, the disclosure clock is running, and your post-incident review says “the credential was valid, the read was authorized” — which is true, and is the whole problem.

What breaks without a data perimeter: exfiltration via leaked keys (the read is authorized), exfiltration via your own roles writing to a foreign bucket (a malicious or compromised principal stages data outward), ingestion of poisoned data from buckets you do not trust, confused-deputy attacks where a foreign principal assumes one of your roles, and the slow-motion leak of accidentally-public AMIs and EBS snapshots that ride along when an account is shared too widely. Each of these is invisible to SCPs because SCPs only constrain your principals acting on resources within your policy’s reach.

Who hits this: any organization past ~20 accounts with real data in S3, credentials in Secrets Manager and KMS, cross-account role assumption, and third-party SaaS vendors (observability, CSPM, backup) with roles in your accounts. It bites hardest on regulated workloads (fintech, health, gov) where a single foreign-principal read is a reportable event, and on platform teams who think SCPs make them safe. The fix is almost never “tighten an IAM policy” — it is “put a guardrail on the resource side and the network side that holds regardless of who is calling.”

To frame the whole field before the deep dive, here is every perimeter dimension this article builds, the control that owns it, the condition key it leans on, and the attack it actually stops:

Perimeter dimension The assertion Control that owns it Primary condition key Attack it stops
Trusted identities Only my-org principals touch my resources RCP (resource side) aws:PrincipalOrgID Foreign/anonymous principal reads your bucket
Trusted resources My principals only touch my-org resources SCP (identity side) aws:ResourceOrgID Your role exfiltrates to a foreign bucket
Trusted networks Requests come only from expected networks RCP + VPCE policy aws:SourceVpce, aws:SourceIp Leaked key replayed from the open internet
Durable EC2 baseline IMDSv2 required; no public AMIs/snapshots Declarative policy (config, not condition) SSRF→credential theft; accidental public sharing
Service-to-service AWS-internal calls keep working (exemptions in all above) aws:PrincipalIsAWSService, aws:SourceOrgID False denials breaking replication/logging

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand AWS Organizations and SCPs: an OU (Organizational Unit) is a container of accounts, policies attach at root/OU/account and inherit downward, and SCPs are deny-by-default-overridable guardrails that filter what your principals may do. You should know IAM policy evaluation — that an explicit Deny always wins, that access requires an Allow somewhere, and what a resource-based policy (an S3 bucket policy, a KMS key policy, a role trust policy) is versus an identity policy. You should be comfortable running aws CLI with a management-account or delegated-admin profile and reading JSON.

This sits at the top of the governance and identity track. It assumes the org foundation from AWS Organizations: SCP Guardrails and Delegated Admin and the landing-zone shape from AWS Control Tower: Multi-Account Landing Zone. It builds directly on IAM evaluation order from IAM Fundamentals: Users, Roles, Policies, Evaluation and the least-privilege patterns in IAM Least Privilege and Permission Boundaries. The network leg leans on VPC Deep Dive: Subnets, Routing, IGW, NAT, Endpoints and AWS PrivateLink: Service Provider and Consumer Cross-Account. The cross-account exception pattern is covered in depth in IAM Cross-Account Roles, External ID, Confused Deputy, Session Policies.

A quick map of who owns and confirms each perimeter leg during a rollout, so you pull the right person into the room:

Perimeter leg What lives here Who usually owns it What confirms it
Org policy types RCP/SCP/declarative enablement Cloud platform / org admin aws organizations describe-organization
Identity perimeter (RCP) Resource-side org check Security / platform CloudTrail AccessDenied on foreign reads
Resource perimeter (SCP) Egress org check Security / platform Denied writes to foreign org IDs
Network perimeter VPC endpoints + aws:SourceVpce Network team VPC Flow Logs + endpoint policy
EC2 baseline (declarative) IMDSv2, public-access blocks Compute / platform aws ec2 get-image-block-public-access-state
Exceptions SaaS roles, cross-org shares Security + app owners Access Analyzer external findings

Core concepts

Five mental models make every later policy obvious.

Authorization is an intersection, and Deny always wins. A request to AWS is allowed only if it survives every applicable policy and no policy denies it. Adding RCPs extends the chain on the resource side:

Request allowed  =  identity policy (or resource-based policy) ALLOW
                AND  every SCP from root to account   (no DENY)
                AND  every RCP from root to account   (no DENY)   <- resource side, ANY caller
                AND  the resource-based policy on the target     (no DENY)

A single Deny anywhere in that chain kills the request, no matter how many Allows exist. RCPs and SCPs are deny-only: they can never grant access, only remove it from whatever was otherwise permitted.

SCPs watch the caller; RCPs watch the resource. An SCP evaluates against the principal making the call — it can only constrain principals inside your org. An RCP evaluates against the resource being acted on, for any caller, including a principal from a foreign org or an anonymous caller redeeming a presigned URL. “My principals on trusted resources” (SCP) versus “trusted identities on my resources” (RCP) is the first fork in every perimeter decision.

The baseline allows everything; you layer additive denies. RCPs start from an implicit RCPFullAWSAccess policy that allows all actions on all resources, exactly like FullAWSAccess for SCPs. You never grant in an RCP — you attach Deny-with-condition statements on top of that baseline. Detach the baseline and you break access org-wide, so never do that; author additive denies instead.

The management account is exempt — keep data out of it. Neither SCPs nor RCPs apply to the management account’s own principals or resources, even when attached at the root. A workload or bucket living in the management account is outside your perimeter. This is also your break-glass anchor: a role in the management account is, by definition, never filtered by the perimeter.

The supported-service list is small and deliberate. At GA, RCPs support exactly S3, STS, SQS, KMS, and Secrets Manager — the precise set of services that hold or broker your data and credentials. That is not a limitation to lament; it is the realistic exfiltration surface. Lock those five on the resource side and you have closed the doors attackers actually walk through.

The hard limits and quotas that shape how you author and attach these policies — hit one of these mid-rollout and the error is cryptic, so know them up front:

Limit / quota RCP SCP Declarative (EC2)
Max policy document size 5,120 characters 5,120 characters 10,000 characters
Max policies attached per entity (root/OU/account) 5 5 5
Max OU nesting depth 5 levels 5 levels 5 levels
Audit-only / report mode None None N/A (config, not deny)
Applies to management account No No No (mgmt acct exempt)
Implicit baseline you must keep RCPFullAWSAccess FullAWSAccess None
Services in scope (GA) 5 (S3/STS/SQS/KMS/Secrets) All EC2 attributes only
Requires all-features Organizations Yes Yes Yes

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary repeats these for lookup; this is the mental model side by side:

Concept One-line definition Side of the request Why it matters to the perimeter
SCP Deny guardrail on your principals Identity (caller) Stops your roles reaching foreign resources
RCP Deny guardrail on your resources Resource (target) Stops any caller reaching your resources
Declarative policy Durable service config baseline Service config Pins IMDSv2, blocks public AMIs/snapshots
RCPFullAWSAccess Implicit allow-all RCP baseline Resource Never detach; layer denies on top
aws:PrincipalOrgID Org ID of the calling principal Condition key Reject foreign principals on your resources
aws:ResourceOrgID Org ID owning the target resource Condition key Reject your principals touching foreign resources
aws:SourceOrgID Org ID of the resource owner in a service call Condition key Exempt AWS-service-on-your-behalf traffic
aws:PrincipalIsAWSService Call made by an AWS service principal Condition key Exempt service-linked / log-delivery calls
aws:SourceVpce VPC endpoint the request traversed Condition key Enforce network trust
Break-glass role Mgmt-account role outside the perimeter Identity Your way back in if a policy wedges you

RCPs vs SCPs: closing the resource-side gap

The mental model that matters most: a request is authorized only if it survives the intersection of every applicable policy, and RCPs add a brand-new term to that intersection on the resource side. SCPs evaluate against the principal making the call; RCPs evaluate against the resource being acted on, regardless of who the caller is — including principals from outside your organization and anonymous callers. That is the entire point, and it is the one thing to internalize before writing a line of JSON.

Here is the full side-by-side. Read every row — the differences are exactly where the perimeter logic lives:

Dimension SCP RCP
Attaches to Root / OU / account Root / OU / account
Evaluates against The principal (caller) The resource (target)
Constrains Your org’s principals The resource, for any caller
Catches external principals? No Yes
Catches anonymous / presigned-URL callers? No Yes
Can it grant access? No — deny only No — deny only
Implicit baseline FullAWSAccess (allow all) RCPFullAWSAccess (allow all)
Affects management account? No No
Max policy size 5,120 characters 5,120 characters
Supported services (GA) All services S3, STS, SQS, KMS, Secrets Manager
Audit-only / report mode No No
Evaluated together with resource-based policy? Independently Yes — RCP Deny overrides bucket/key policy Allow

Two consequences shape everything below, and both are easy to get wrong:

RCPs start from an implicit RCPFullAWSAccess baseline that allows everything, exactly like FullAWSAccess for SCPs. You layer deny-with-condition statements on top. If you detach the baseline you break access org-wide, so never do that — author additive denies instead.

The management account is exempt from both SCPs and RCPs. Attaching at the root does not protect the management account’s own resources. Keep workloads and data out of it, and use a management-account role as your break-glass path.

The supported-service list is small but deliberately chosen — it is precisely the set of services that hold or broker data and credentials. Here is what each supported service contributes to the perimeter and the headline action you most want to govern:

Service What it holds / brokers Headline action to govern Exfiltration path it closes
S3 Your object data s3:GetObject, s3:PutObject Presigned-URL read; write to foreign bucket
STS Temporary credentials sts:AssumeRole* Foreign principal assuming your role (confused deputy)
KMS Encryption keys kms:Decrypt, kms:GenerateDataKey Decrypting your data with a leaked grant
SQS Queued messages / events sqs:SendMessage, sqs:ReceiveMessage Draining a queue from outside the org
Secrets Manager Credentials & secrets secretsmanager:GetSecretValue Pulling DB/API secrets with a leaked key

A note on why STS is the highest-leverage line in the whole perimeter: role assumption is the front door to nearly every privilege-escalation and lateral-movement path. Denying assumption of your roles by foreign principals shuts a class of confused-deputy attacks at the resource boundary, before any downstream permission even comes into play. If you only had room for one RCP statement, it would be the sts:AssumeRole deny.

The three trust dimensions

A data perimeter is the cross-product of three statements that should all be true for every legitimate request. Miss one corner and you have a perimeter with a hole in it:

  1. Trusted identities — only principals from my org touch my resources (aws:PrincipalOrgID), enforced by RCP.
  2. Trusted resources — my principals only touch resources in my org (aws:ResourceOrgID), enforced by SCP.
  3. Trusted networks — requests only come from my expected networks (aws:SourceVpce, aws:SourceVpc, aws:SourceIp), enforced by RCP + VPC endpoint policy.

RCPs own the trusted-identities-on-my-resources corner. SCPs own my-principals-on-trusted-resources. VPC endpoint policies and aws:SourceVpce conditions own trusted networks. Map every control you write back to one of these cells, or you are adding noise. The mapping is worth memorizing as a grid:

Trust dimension Enforced by On which side Condition key(s) Without it…
Trusted identities RCP Resource aws:PrincipalOrgID Foreign principal reads your data
Trusted resources SCP Identity aws:ResourceOrgID Your principal writes to attacker’s bucket
Trusted networks RCP + VPCE policy Resource + endpoint aws:SourceVpce, aws:SourceVpc, aws:SourceIp Leaked key works from any IP on earth

The condition keys you will lean on, what each means, and where it shines — get the semantics exactly right because a wrong key is a silent hole:

Key Type Evaluates Where it shines Common mistake
aws:PrincipalOrgID String Org ID of the caller RCP: reject foreign principals Using it in an SCP (it’s caller-side there, redundant)
aws:ResourceOrgID String Org ID owning the target resource SCP: stop writes to foreign resources Expecting RCP to enforce it (resource is foreign, out of reach)
aws:SourceOrgID String Org ID of the resource owner in an AWS-service call RCP: scope service-principal access Forgetting it → breaks S3 replication, log delivery
aws:PrincipalIsAWSService Bool Request made by an AWS service principal RCP: exempt service-to-service calls Forgetting it → denies service-linked roles
aws:PrincipalOrgPaths String (multi) OU path of the caller Scope to specific OUs Path format typos (o-x/r-x/ou-x/)
aws:SourceVpce String VPC endpoint ID the request traversed Network trust No Null/IfExists guard → denies console
aws:SourceVpc String VPC ID the request originated from Broader network trust Confusing it with aws:SourceVpce
aws:SourceIp IP Public source IP of the caller Allowlist office / egress IPs Breaks on NAT/proxy IP churn
aws:ViaAWSService Bool Call made via another AWS service Exempt indirect service calls Conflating with aws:PrincipalIsAWSService
sts:ExternalId String Agreed secret on role assumption SaaS vendor carve-out Reusing the same ExternalId across vendors

The IfExists operator suffix deserves its own note, because it is the single most common cause of accidental lockouts. Here is how each operator behaves when the key is absent from the request — the difference between a clean perimeter and a 2 a.m. page:

Operator form Behaviour when key present Behaviour when key absent Use it when
StringNotEquals Deny if value differs Deny fires (key missing ≠ your value) You are certain the key is always present
StringNotEqualsIfExists Deny if value differs Condition skipped (no deny) Almost always — avoids stray denials
Null: {"key":"true"} Matches only when key is absent Scope a statement to “no key present”
Null: {"key":"false"} Matches only when key is present “Only apply when the request had this key”
Bool: {"...":"false"} Matches when bool is false Depends on IfExists Pair with IfExists for service exemptions

Enable RCPs and author the identity perimeter

RCPs are a policy type you turn on at the root, then attach. Enable it from the management account (or a delegated Organizations admin). First find the root, then enable the type:

# Find the root ID
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)

# Enable the Resource Control Policy type on the org
aws organizations enable-policy-type \
  --root-id "$ROOT_ID" \
  --policy-type RESOURCE_CONTROL_POLICY
# Terraform equivalent — enable both policy types on the org
resource "aws_organizations_organization" "this" {
  feature_set = "ALL"
  enabled_policy_types = [
    "SERVICE_CONTROL_POLICY",
    "RESOURCE_CONTROL_POLICY",
    "DECLARATIVE_POLICY_EC2",
  ]
}

Now the core control: deny any access to S3, STS, SQS, KMS, and Secrets Manager resources unless the calling principal belongs to my org or the call is made by an AWS service on my behalf. The two exception conditions are critical — without them you break replication, AWS service integrations, and a long tail of legitimate cross-service traffic.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceOrgIdentityPerimeter",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "s3:*",
        "sts:AssumeRole",
        "sts:AssumeRoleWithSAML",
        "sts:AssumeRoleWithWebIdentity",
        "sqs:*",
        "kms:*",
        "secretsmanager:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:PrincipalOrgID": "o-abc123def4",
          "aws:SourceOrgID": "o-abc123def4"
        },
        "BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
      }
    }
  ]
}

Why each piece is the way it is — this is the part teams copy without understanding and then debug for a week:

Element What it does If you omit / change it
"Effect": "Deny" Additive deny over the allow-all baseline RCPs cannot allow; deny is the only option
"Principal": "*" Applies to every caller, incl. anonymous Required in an RCP — the resource side has no implicit principal
StringNotEqualsIfExists Deny only when key present and ≠ your value StringNotEquals (no IfExists) fires on absent keys → breaks service calls
aws:PrincipalOrgID + aws:SourceOrgID in one block OR — deny only if principal is foreign and source-org mismatches Splitting them into two blocks makes it an AND-of-denies → over-blocks
aws:SourceOrgID Exempts a service principal acting under a resource you own Omit → S3 replication, CloudTrail-to-bucket writes denied
BoolIfExists aws:PrincipalIsAWSService false Exempts service-linked calls carrying no org key Omit → log delivery / SLR traffic denied
sts:AssumeRole* enumerated Names the three assume actions explicitly sts:* would also catch GetCallerIdentity etc. (usually harmless, but noisy)

Attach it — and start at a sandbox OU (covered in the rollout section), never the root:

# Create the policy, capture its ID, attach to the sandbox OU first
RCP_ID=$(aws organizations create-policy \
  --name "rcp-identity-perimeter" \
  --type RESOURCE_CONTROL_POLICY \
  --content file://rcp-identity-perimeter.json \
  --query 'Policy.PolicySummary.Id' --output text)

aws organizations attach-policy \
  --policy-id "$RCP_ID" \
  --target-id ou-root-sandbox01
resource "aws_organizations_policy" "identity_perimeter" {
  name    = "rcp-identity-perimeter"
  type    = "RESOURCE_CONTROL_POLICY"
  content = file("${path.module}/rcp-identity-perimeter.json")
}

resource "aws_organizations_policy_attachment" "identity_perimeter_sandbox" {
  policy_id = aws_organizations_policy.identity_perimeter.id
  target_id = "ou-root-sandbox01" # promote to non-prod → prod → root in waves
}

The actions to enumerate per service, and the trade-off between the broad wildcard and a tight list, so you choose deliberately:

Service Broad (service:*) Tight (enumerate) Recommendation
S3 s3:* s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket Start broad; data-plane is what matters
STS sts:* sts:AssumeRole, sts:AssumeRoleWithSAML, sts:AssumeRoleWithWebIdentity Tight — avoid catching identity-introspection calls
KMS kms:* kms:Decrypt, kms:GenerateDataKey, kms:ReEncrypt* Broad is fine; KMS is all sensitive
SQS sqs:* sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage Broad is fine
Secrets Manager secretsmanager:* secretsmanager:GetSecretValue, secretsmanager:BatchGetSecretValue Broad is fine; read is the exfil path

The resource side of trusted resources (SCP)

RCPs stop the world from reaching in. To stop your principals from reaching out — exfiltrating to a bucket in an attacker’s account — you need an SCP keyed on aws:ResourceOrgID. RCPs cannot express this because the target resource is foreign and outside your policy’s reach; only an identity-side SCP on your principals can constrain where they write.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAccessToForeignResources",
      "Effect": "Deny",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "sqs:SendMessage",
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:ResourceOrgID": "o-abc123def4"
        },
        "BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
      }
    }
  ]
}

This is the pair to the RCP: together they assert “my principals only touch my resources, and my resources are only touched by my principals.” Exempt the AWS service principal again, or you will break things like Systems Manager pulling a public patch baseline (which legitimately lives outside your org). The two policies are mirror images — keep them straight with this table:

Statement Policy type Reads Denies when Exemption
EnforceOrgIdentityPerimeter RCP (resource side) aws:PrincipalOrgID of caller Caller’s org ≠ yours aws:SourceOrgID, aws:PrincipalIsAWSService
DenyAccessToForeignResources SCP (identity side) aws:ResourceOrgID of target Target resource’s org ≠ yours aws:PrincipalIsAWSService

What each layer cannot do alone — the reason you need both, stated as a coverage matrix:

Threat RCP alone SCP alone Both
Foreign principal reads your S3 bucket Blocked Not blocked Blocked
Your role writes to attacker’s S3 bucket Not blocked Blocked Blocked
Anonymous presigned-URL read of your bucket Blocked Not blocked Blocked
Your role pulls poisoned data from foreign bucket Not blocked Blocked Blocked
AWS service replicating to your DR bucket Allowed (exempt) Allowed (exempt) Allowed

A subtle reason this SCP must be tightly scoped to a few actions rather than s3:*: an over-broad aws:ResourceOrgID deny on all S3 actions can catch s3:ListAllMyBuckets and other account-level calls where aws:ResourceOrgID is absent, and with StringNotEqualsIfExists that is fine — but if you ever drop the IfExists, every such call denies. Enumerate the data-plane actions you actually mean.

Trusted networks with VPC endpoints and aws:SourceVpce

Identity trust alone is not enough: a leaked long-lived key for one of your roles still belongs to your org, so aws:PrincipalOrgID passes cleanly. The network dimension is what catches it — that stolen key is being used from the open internet, not from inside your VPC. This is the leg that actually stops the presigned-URL-from-a-coffee-shop scenario.

Enforce it in two layers. First, on the VPC endpoint policy for the S3 gateway/interface endpoint, restrict to your org so the endpoint itself cannot be used as a tunnel to foreign buckets:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EndpointOrgScopeOnly",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "StringEquals": { "aws:ResourceOrgID": "o-abc123def4" }
      }
    }
  ]
}

Second, layer an RCP that denies S3 data-plane calls unless they arrive through an approved endpoint or from an expected network. Use ...IfExists so console and AWS-service paths that legitimately lack these keys are not collateral damage:

{
  "Sid": "EnforceNetworkPerimeterS3",
  "Effect": "Deny",
  "Principal": "*",
  "Action": ["s3:GetObject", "s3:PutObject"],
  "Resource": "*",
  "Condition": {
    "StringNotEqualsIfExists": {
      "aws:SourceVpce": ["vpce-0a1b2c3d4e5f", "vpce-9z8y7x6w5v4u"]
    },
    "BoolIfExists": {
      "aws:PrincipalIsAWSService": "false",
      "aws:ViaAWSService": "false"
    },
    "Null": { "aws:SourceVpce": "false" }
  }
}

The Null check on aws:SourceVpce set to false means “this condition only applies when the request actually traversed a VPC endpoint” — combined with the service exemptions, it avoids denying console traffic that never had a VPCE in the first place. Tune the exact keys to your topology; the principle is to fail closed only for traffic you genuinely expect to be endpoint-bound.

The network condition keys, what each catches, and the guard each needs — the difference between a tight net and a self-inflicted outage:

Key Catches Guard it needs Failure mode without the guard
aws:SourceVpce Request not via an approved endpoint Null: false + service exemptions Denies console (no VPCE) and service calls
aws:SourceVpc Request not from an approved VPC IfExists Denies AWS-internal traffic lacking the key
aws:SourceIp Request not from an allowlisted public IP IfExists; accounts for VPCE Denies private-endpoint traffic (no public IP)
aws:ViaAWSService Indirect service calls pair with IfExists Over-blocks legitimate service-mediated calls

A decision table for which network key to reach for, given your topology:

If your access is… Use Why
All through interface/gateway VPC endpoints aws:SourceVpce allowlist Most precise; ties to specific endpoints
From whole VPCs, mixed endpoint usage aws:SourceVpc allowlist Coarser but resilient to endpoint churn
From fixed corporate egress IPs (no VPCE) aws:SourceIp allowlist Works for on-prem / office traffic
A mix of the above Combine with OR semantics in one block Pass if any trusted path matches

Durable EC2 controls with Declarative Policies

SCPs and RCPs deny actions. Declarative Policies are a different animal: they set a baseline service configuration that the service itself enforces and reports, and the setting persists even as AWS ships new APIs. For EC2 they are the cleanest way to mandate IMDSv2 and kill public sharing across the entire org — including future accounts — without writing a deny for every relevant action and chasing every new API AWS releases.

Enable the policy type, then attach a declarative policy that requires IMDSv2 on instance launch and blocks public AMI and public EBS snapshot sharing:

aws organizations enable-policy-type \
  --root-id "$ROOT_ID" \
  --policy-type DECLARATIVE_POLICY_EC2
{
  "ec2_attributes": {
    "exception_message": {
      "value": "Blocked by org data-perimeter declarative policy. Contact #cloud-platform."
    },
    "instance_metadata_defaults": {
      "http_tokens": { "value": "required" }
    },
    "image_block_public_access": {
      "state": { "value": "block_new_sharing" }
    },
    "snapshot_block_public_access": {
      "state": { "value": "block_all_sharing" }
    }
  }
}

What this buys you over a stack of SCP denies — and why declarative is the right tool here specifically:

Attribute What it enforces Values What it neutralizes
instance_metadata_defaults.http_tokens IMDSv2 as the account default required, optional, no_preference SSRF → IMDSv1 → credential theft
instance_metadata_defaults.http_put_response_hop_limit Metadata hop limit integer (e.g. 164) Container-escape token theft (set low)
instance_metadata_defaults.http_endpoint Whether IMDS is reachable enabled, disabled, no_preference Disable IMDS entirely where unused
image_block_public_access.state Blocks making AMIs public block_new_sharing, unblocked Accidental public AMI exposure
snapshot_block_public_access.state Blocks making EBS snapshots public block_all_sharing, block_new_sharing, unblocked Accidental public snapshot data leak
serial_console_access.state EC2 serial console availability enabled, disabled Out-of-band console access surface
exception_message.value User-facing message on a block free text Cryptic UnauthorizedOperation confusion

Declarative vs SCP-deny for the same EC2 outcome — the head-to-head that explains the choice:

Goal Declarative policy Equivalent SCP deny Why declarative wins
Require IMDSv2 http_tokens: required (one line) Deny ec2:RunInstances unless ec2:MetadataHttpTokens = required Persists across new launch APIs; sets the default
Block public AMIs image_block_public_access: block_new_sharing Deny ec2:ModifyImageAttribute with public add Service-enforced + reported, not just blocked
Block public snapshots snapshot_block_public_access: block_all_sharing Deny ec2:ModifySnapshotAttribute public add Covers all sharing paths structurally
Self-documenting failure exception_message surfaced to user None — generic UnauthorizedOperation Operators know why and who to contact

The headline wins: http_tokens: required forces IMDSv2 as the account default, neutering the SSRF-to-credential-theft path behind multiple high-profile breaches — new instances inherit it with no per-instance config. image_block_public_access and snapshot_block_public_access make accidental public AMI/snapshot sharing structurally impossible. exception_message is surfaced to the user on a block, so the failure is self-documenting. Attach to the sandbox OU first, verify with the service-state APIs, then promote:

# Verify the declarative policy actually took effect at the service layer
aws ec2 get-image-block-public-access-state --region ap-south-1
aws ec2 get-snapshot-block-public-access-state --region ap-south-1
aws ec2 get-instance-metadata-defaults --region ap-south-1

Staged rollout without locking yourself out

This is where teams either succeed or page themselves at 2 a.m. The discipline is simple to state and easy to skip: build the OU shape so you can roll forward, not just out. Attach to a leaf sandbox OU, then a non-prod OU, then prod, then root last. Org policy inheritance is cumulative, so a deny that is correct at the root is the last thing you attach, never the first.

Root                         <- attach RCP/SCP here LAST, after weeks of evidence
 |- OU Sandbox  (1 account)  <- attach FIRST, break things here on purpose
 |- OU NonProd               <- second wave
 |- OU Prod                  <- third wave
 |- OU Security              <- exempt or test in isolation

The wave plan as a table — what you attach where, what you watch, and the go/no-go gate for promoting:

Wave Target What you attach What you watch Go/no-go to promote
0 (none — pre-flight) Nothing CloudTrail Lake query (90 days) Every foreign relationship allowlisted
1 OU Sandbox (1 acct) Identity RCP + SCP + declarative AccessDenied events; app smoke tests Zero unexpected denies after soak
2 OU NonProd Same set CI/CD, integration tests, vendor roles No pipeline breakage for 1 week
3 OU Prod Same set Prod error rates, dependency calls No customer-facing incident for 2 weeks
4 Root Same set Org-wide AccessDenied; Access Analyzer All exceptions documented; break-glass verified

Gather evidence before you deny anything. RCPs and SCPs have no audit-only mode, so you simulate the deny with a CloudTrail query first. Find any request in the last 90 days that your perimeter would have blocked — i.e., access to your resources by a principal whose org ID is not yours:

-- CloudTrail Lake: would the identity perimeter have broken anything?
SELECT eventTime, eventSource, eventName,
       userIdentity.arn AS principal,
       userIdentity.accountId AS caller_account,
       sourceIPAddress
FROM cloudtrail_event_data_store
WHERE eventSource IN ('s3.amazonaws.com', 'sts.amazonaws.com',
                      'kms.amazonaws.com', 'secretsmanager.amazonaws.com')
  AND userIdentity.accountId NOT IN (SELECT account_id FROM my_org_accounts)
  AND userIdentity.principalId != 'AWSService'
  AND eventTime > timestamp '2026-03-01 00:00:00'
ORDER BY eventTime DESC;

Every row is a relationship you must explicitly allowlist (next section) before the deny goes live. Zero rows in the sandbox after a soak period is your green light to promote. The pre-flight queries you should run, one per perimeter leg:

Pre-flight question Filter on A row means
Foreign principal reading my resources? userIdentity.accountId NOT IN (org) Identity-RCP would deny it — allowlist or expect breakage
My principal touching a foreign resource? requestParameters target ARN’s account ∉ org Resource-SCP would deny it
Data-plane call with no VPCE? vpcEndpointId IS NULL on s3 data events Network-RCP would deny it
Service principal acting on my bucket? userIdentity.type = 'AWSService' Must stay exempt — verify aws:SourceOrgID covers it
IMDSv1 still in use? requestParameters.MetadataHttpTokens != 'required' Declarative http_tokens: required will flip it

Keep an out-of-band break-glass role in the management account that is, by definition, never subject to RCPs or SCPs. If a perimeter policy ever wedges your delegated admin, that is your way back in. Test it before wave 1, not during an incident.

Exceptions: AWS services, SaaS roles, cross-org sharing

A real perimeter has documented holes. Manage them as named exceptions, not by weakening the baseline — the moment you relax the baseline “just for this vendor,” you have lost the perimeter.

AWS service principals — already handled by the aws:PrincipalIsAWSService / aws:SourceOrgID conditions in the identity RCP. Resist the urge to add per-service Sids; the generic exemption is more robust as AWS adds services.

Third-party SaaS roles — a vendor (observability, CSPM, backup) assumes a role in your account from their account, so aws:PrincipalOrgID will not match. Do not punch a hole in the RCP. Instead, scope the role trust policy precisely with the vendor’s account and the sts:ExternalId they issued you, and carve that single role out of the RCP by ARN:

{
  "Sid": "AllowVendorWithExternalId",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::210987654321:root" },
  "Action": "sts:AssumeRole",
  "Condition": {
    "StringEquals": { "sts:ExternalId": "kloudvin-prod-7f3a9c2e" }
  }
}

To keep the RCP intact while permitting this single foreign principal, add an ArnNotLike carve-out for that role to the RCP’s deny condition so the vendor role is the only external ARN allowed through:

"Condition": {
  "StringNotEqualsIfExists": { "aws:PrincipalOrgID": "o-abc123def4" },
  "ArnNotLikeIfExists": {
    "aws:PrincipalArn": "arn:aws:iam::*:role/vendor/ObservabilityIngest"
  },
  "BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}

Cross-org data sharing — when you genuinely share a bucket with a partner org, scope it to their org ID via aws:PrincipalOrgID on the bucket policy and add their org to a dedicated, reviewed allowlist in the RCP. Never relax to Principal: "*" without a condition.

The exception catalogue — every legitimate hole, how to scope it, and the wrong way teams do it:

Exception Right way to scope it Wrong way (do not) Review cadence
AWS service principals aws:SourceOrgID + aws:PrincipalIsAWSService (generic) Per-service Sids you maintain by hand Set once; revisit on service adoption
SaaS vendor role Trust policy: vendor account + sts:ExternalId; RCP ArnNotLike carve-out Open Principal:"*" or remove the org check Quarterly + on vendor change
Partner cross-org S3 share Bucket policy aws:PrincipalOrgID = partner org; RCP allowlist their org ID Drop the RCP org condition entirely Quarterly
DR replication to separate org Add DR org ID to the RCP StringNotEquals OR set Disable replication denial broadly On DR topology change
Break-glass admin Management-account role (inherently exempt) A role inside a workload OU you “trust” Test before each wave

The condition operators you’ll use for carve-outs, and exactly what each matches:

Operator Matches Use for
ArnNotLike (with IfExists) Principal ARN does not match a wildcard pattern Allowing one vendor role through a deny
StringNotEquals (multi-value) None of the listed org IDs match Allowlisting multiple partner/DR orgs
StringEquals on sts:ExternalId The agreed shared secret matches Confused-deputy-safe vendor assumption
ForAllValues:StringEquals Every value in a multi-value key is in your set Tag/OU-path allowlists

Architecture at a glance

The diagram traces a request as it actually flows through the perimeter, left to right, and pins each failure/control onto the exact hop where it bites. Read it as the path a call takes: a caller — which may be one of your principals inside a VPC, a foreign principal, or an anonymous client redeeming a presigned URL — sends an API request. Before it ever reaches your data, it passes the network perimeter (the VPC endpoint and the aws:SourceVpce RCP that fails closed if the call did not come through an approved endpoint), then the identity perimeter (the resource-side RCP checking aws:PrincipalOrgID, which rejects any caller from outside your org while exempting AWS service principals via aws:SourceOrgID), and only then reaches the protected data plane — S3, STS, KMS, SQS, and Secrets Manager. Running alongside, the resource perimeter SCP watches the outbound direction so your own principals cannot write to a foreign bucket, and the declarative EC2 baseline sits to the side pinning IMDSv2 and blocking public AMIs and snapshots on every account, present and future.

The numbered badges mark the five places this perimeter most commonly fails or fires: a network-RCP false-deny on console traffic that lacked a VPCE (badge 1), the identity-RCP rejecting a foreign or presigned-URL caller (badge 2 — the win condition), an over-broad deny accidentally blocking an AWS service principal because aws:SourceOrgID was omitted (badge 3), the resource-SCP catching an exfiltration write to a foreign org (badge 4), and the declarative baseline flipping an IMDSv1 instance to IMDSv2-required (badge 5). The whole method is in that path: every request must clear network trust and identity trust before it touches data, your principals are simultaneously fenced from foreign resources, and the EC2 fleet is pinned to a safe baseline that survives new APIs. Follow the arrows and you can see precisely why a leaked key replayed from a coffee shop returns AccessDenied while S3 replication to your DR org keeps flowing.

AWS data-perimeter architecture tracing an API request left to right through four control zones — a caller (org principal, foreign principal, or anonymous presigned-URL client) passing the network perimeter (S3 VPC endpoint plus an aws:SourceVpce RCP that fails closed without an approved endpoint), then the identity perimeter (a resource-side RCP checking aws:PrincipalOrgID that rejects foreign callers while exempting AWS service principals via aws:SourceOrgID), reaching the protected data plane of S3, STS, KMS, SQS and Secrets Manager, with a resource-perimeter SCP fencing outbound writes to foreign orgs via aws:ResourceOrgID and a declarative EC2 policy pinning IMDSv2 and blocking public AMIs and snapshots — numbered badges marking the network false-deny on console traffic, the identity-RCP foreign-caller rejection, the omitted-aws:SourceOrgID service false-deny, the resource-SCP exfiltration block, and the declarative IMDSv2 flip

Real-world scenario

Meridian Pay, a fintech platform team, ran roughly 180 accounts with a clean SCP layer and felt well-governed — until a red-team engagement exfiltrated a customer dataset using a leaked CI/CD access key. The key, scraped from a verbose build log, generated an S3 presigned URL, and the data was pulled from a coffee-shop IP to an attacker-controlled bucket in a foreign account. The SCPs did nothing: the key was a legitimate org principal and the read was authorized. The board asked the one question that mattered — “if this were real, what stops it?” — and the honest answer was nothing we have today.

The constraint was brutal, because Meridian Pay’s data plane was a web of legitimate cross-account flows: a central data lake read by twelve consumer accounts, three SaaS integrations (observability, CSPM, and a backup vendor) with roles in production, and — the killer — S3 replication to a DR account in a separate org mandated by their regulator for blast-radius isolation. A blunt aws:PrincipalOrgID deny would have taken down the DR replication and all three SaaS integrations on day one, turning a security improvement into a self-inflicted outage and a failed audit.

They solved it with a layered RCP plus a network deny, rolled through the OU waves over three weeks. The DR-org replication was handled by adding the DR org’s ID to the StringNotEquals OR set so cross-org replication kept flowing; the three SaaS roles were carved out by ARN with sts:ExternalId on their trust policies; and the presigned-URL exfil path was killed by the network RCP — a presigned URL carries the signer’s identity, but the redemption came from outside any VPC endpoint, so aws:SourceVpce failed closed:

{
  "Sid": "DenyDataReadOutsideOrgAndNetwork",
  "Effect": "Deny",
  "Principal": "*",
  "Action": ["s3:GetObject"],
  "Resource": "*",
  "Condition": {
    "StringNotEqualsIfExists": {
      "aws:PrincipalOrgID": ["o-abc123def4", "o-dr9876fedc5"],
      "aws:SourceOrgID": "o-abc123def4"
    },
    "Null": { "aws:SourceVpce": "false" },
    "StringNotEqualsIfExists": {
      "aws:SourceVpce": ["vpce-0a1b2c3d4e5f"]
    },
    "BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
  }
}

The CloudTrail Lake pre-flight surfaced exactly four foreign-principal relationships — the DR org, and the three vendor roles. All four were allowlisted before go-live; the rollout caused zero production incidents. The same leaked key, replayed by the red team post-deployment from the same coffee-shop IP, returned AccessDenied — the presigned URL was valid, the signer was a real org principal, but the redemption came from no trusted endpoint and the network RCP failed it closed.

The incident-to-fix arc as a timeline, because the order is the lesson:

Stage What happened Control state Outcome
Baseline Clean SCP layer, no RCP/network leg Identity-side only Felt governed
Red team Leaked CI/CD key → presigned URL → exfil SCPs allow (valid principal) Data exfiltrated
Pre-flight CloudTrail Lake 90-day query Evidence gathered 4 foreign relationships found
Wave 1–2 RCP+SCP+network on sandbox→non-prod DR + 3 vendors allowlisted Zero breakage
Wave 3–4 Promote to prod → root Full perimeter live Replication + vendors intact
Re-test Same key replayed post-deploy Network RCP fails closed AccessDenied

The lesson on their wall afterward: “SCPs govern your people. A perimeter governs everyone — including the attacker holding your own key.”

Advantages and disadvantages

The RCP-plus-declarative model both closes the resource-side gap and introduces real operational weight. Weigh it honestly before you commit an org to it:

Advantages (why this model protects you) Disadvantages (why it bites)
Holds even when a valid credential leaks — the read is denied on the resource side regardless of caller No audit-only mode for RCP/SCP — you must simulate denies via CloudTrail before attaching, or break things blind
Catches external and anonymous callers (presigned URLs) that SCPs structurally cannot Cumulative inheritance means a root-level deny is hard to roll back without org-wide impact
Declarative policies persist across new APIs — no chasing every new EC2 action with a fresh deny RCP supports only 5 services at GA — DynamoDB, EFS, RDS data, etc. are not yet covered on the resource side
aws:SourceOrgID / aws:PrincipalIsAWSService exemptions keep service-to-service traffic working A single missing IfExists can lock out an entire OU’s console, including yours
One http_tokens: required line neutralizes SSRF→credential theft fleet-wide Declarative EC2 attributes are EC2-specific; other services need their own controls
IAM Access Analyzer at the org zone-of-trust continuously proves the perimeter holds Exceptions (SaaS, cross-org) require ongoing review — a perimeter is never “done”
exception_message makes blocks self-documenting for users The management account stays exempt — easy to forget it is outside the perimeter

The model is right for any org past ~20 accounts with real data, credentials, cross-account flows, and a regulatory or risk reason to assume credentials will leak. It bites hardest on teams without a CloudTrail Lake / Athena pre-flight habit (they break things), teams that conflate SCP and RCP semantics (they write redundant or wrong policies), and orgs that treat exceptions as one-time rather than reviewed. Every disadvantage is manageable — but only with the staged rollout, the pre-flight, and the break-glass path this article insists on.

Hands-on lab

Stand up a minimal identity perimeter on a sandbox OU, prove a foreign-style read is denied, and tear it down. This is free — Organizations policies cost nothing; you pay only for any test data you create. Run with a management-account or delegated-admin profile.

Step 1 — Capture the org and root IDs.

ORG_ID=$(aws organizations describe-organization --query 'Organization.Id' --output text)
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
echo "Org: $ORG_ID  Root: $ROOT_ID"

Expected: an o-xxxxxxxxxx org ID and an r-xxxx root ID.

Step 2 — Enable the RCP policy type (idempotent; ignore “already enabled”).

aws organizations enable-policy-type \
  --root-id "$ROOT_ID" --policy-type RESOURCE_CONTROL_POLICY 2>/dev/null || true
aws organizations describe-organization \
  --query 'Organization.AvailablePolicyTypes' --output table

Expected: a row showing RESOURCE_CONTROL_POLICY with status ENABLED.

Step 3 — Create a sandbox OU (or reuse one) and note its ID.

OU_ID=$(aws organizations create-organizational-unit \
  --parent-id "$ROOT_ID" --name "sandbox-perimeter-lab" \
  --query 'OrganizationalUnit.Id' --output text)
echo "Sandbox OU: $OU_ID"

Step 4 — Author the identity-perimeter RCP, substituting your org ID.

cat > /tmp/rcp-identity.json <<JSON
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "EnforceOrgIdentityPerimeter",
    "Effect": "Deny",
    "Principal": "*",
    "Action": ["s3:GetObject","s3:PutObject","sts:AssumeRole",
               "kms:Decrypt","secretsmanager:GetSecretValue"],
    "Resource": "*",
    "Condition": {
      "StringNotEqualsIfExists": {
        "aws:PrincipalOrgID": "$ORG_ID",
        "aws:SourceOrgID": "$ORG_ID"
      },
      "BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
    }
  }]
}
JSON

Step 5 — Create and attach the policy to the sandbox OU only.

RCP_ID=$(aws organizations create-policy \
  --name "rcp-lab-identity-perimeter" --type RESOURCE_CONTROL_POLICY \
  --content file:///tmp/rcp-identity.json \
  --query 'Policy.PolicySummary.Id' --output text)

aws organizations attach-policy --policy-id "$RCP_ID" --target-id "$OU_ID"
aws organizations list-policies-for-target \
  --target-id "$OU_ID" --filter RESOURCE_CONTROL_POLICY --output table

Expected: the attach succeeds and the list shows rcp-lab-identity-perimeter.

Step 6 — Prove it (conceptually) and read the CloudTrail evidence. From a sandbox-account principal, a read by your org principal still works (your org ID matches); a read attempted by a principal outside your org would be denied. Confirm what would fire:

# In CloudTrail (or CloudTrail Lake), a denied foreign read shows errorCode AccessDenied
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \
  --max-results 5 --query 'Events[].CloudTrailEvent' --output text

Validation checklist — what each step proved, mapped to the real-world move:

Step What you did What it proves Production analogue
2 Enabled RESOURCE_CONTROL_POLICY The type must be on before attach First-time org enablement
4 Authored deny with IfExists + exemptions The baseline is allow-all; you add denies Writing the real identity RCP
5 Attached to sandbox OU only Inheritance is scoped; root is last Wave-1 of the staged rollout
6 Read CloudTrail for denies Evidence proves the perimeter, not faith Post-rollout verification

Cleanup (no lingering cost, but tidy up).

aws organizations detach-policy --policy-id "$RCP_ID" --target-id "$OU_ID"
aws organizations delete-policy --policy-id "$RCP_ID"
aws organizations delete-organizational-unit --organizational-unit-id "$OU_ID"

Cost note. Organizations policies, OUs, and attachments are free. The only charges in this lab come from any S3/KMS test objects you create — pennies, and deleting them stops everything.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First as a scannable table you can read mid-rollout, then the entries that bite hardest with full confirm-command detail.

# Symptom Root cause Confirm (exact cmd / path) Fix
1 Whole OU loses console/S3 access right after attach StringNotEquals without IfExists — deny fires on absent keys CloudTrail AccessDenied on calls lacking aws:PrincipalOrgID Switch to StringNotEqualsIfExists
2 S3 replication / CloudTrail-to-bucket writes start failing aws:SourceOrgID exemption omitted CloudTrail AccessDenied with userIdentity.type = AWSService Add aws:SourceOrgID to the OR block
3 Log delivery / service-linked roles denied aws:PrincipalIsAWSService exemption missing Denied events with no org key and SLR ARN Add BoolIfExists aws:PrincipalIsAWSService false
4 Console S3 access denied after network RCP aws:SourceVpce deny lacks Null:false guard Denied events from console with no vpcEndpointId Add Null:{"aws:SourceVpce":"false"} + service exemptions
5 Vendor SaaS role suddenly can’t assume Foreign principal hits the org-ID deny CloudTrail AssumeRole AccessDenied from vendor account ArnNotLike carve-out + sts:ExternalId on trust policy
6 DR replication to separate org breaks DR org ID not in the StringNotEquals OR set Denied s3:Replicate*/PutObject to DR account Add DR org ID to the allowlist set
7 “Cannot attach policy: type not enabled” RESOURCE_CONTROL_POLICY not enabled on root describe-organization shows type absent enable-policy-type RESOURCE_CONTROL_POLICY
8 RCP attaches but never denies anything Attached to a branch above the resources, or baseline detached list-policies-for-target; check RCPFullAWSAccess present Attach at correct OU; re-add baseline
9 Management-account bucket still world-readable Management account is exempt from RCP/SCP Resource lives in mgmt account Move data out of mgmt account
10 Two org-ID denies in separate blocks over-block Split into two statements → AND-of-denies Read the policy: each foreign condition denies independently Combine into one StringNotEquals* block (OR)
11 IMDSv1 instances still launching Declarative DECLARATIVE_POLICY_EC2 not enabled/attached get-instance-metadata-defaults shows optional Enable type; attach declarative policy
12 Public AMI shared by accident despite policy image_block_public_access set to unblocked or not inherited get-image-block-public-access-state = unblocked Set block_new_sharing; attach at OU
13 Policy edit rejected — “exceeds maximum size” RCP/SCP body > 5,120 characters Count chars; whitespace counts Trim, split logically, or remove redundant Sids
14 Deny works in sandbox, breaks unrelated app in prod A prod-only foreign relationship never appeared in sandbox pre-flight Re-run CloudTrail Lake scoped to prod accounts Allowlist it; never skip the per-wave pre-flight

The expanded form, with the full reasoning for the entries that cost the most hours:

1. Whole OU loses console and S3 access immediately after you attach the RCP. Root cause: You used StringNotEquals instead of StringNotEqualsIfExists. Many legitimate requests — console calls, some service paths — arrive without aws:PrincipalOrgID at all. With plain StringNotEquals, “key absent” is treated as “not equal to your org,” so the deny fires on everything. Confirm: CloudTrail shows AccessDenied on calls where aws:PrincipalOrgID is absent; the denied principals include your own console sessions. Fix: Change every StringNotEquals in the perimeter to StringNotEqualsIfExists. This is the single most common self-inflicted outage in RCP rollouts.

2. S3 replication or CloudTrail-to-bucket writes start failing after the identity RCP goes live. Root cause: You omitted aws:SourceOrgID from the exemption. When an AWS service (S3 replication, CloudTrail log delivery) acts on a resource you own, the call carries aws:SourceOrgID (your org) but not necessarily aws:PrincipalOrgID. Without exempting aws:SourceOrgID, the deny catches your own service traffic. Confirm: CloudTrail AccessDenied events with userIdentity.type = AWSService against your buckets, timed to the attach. Fix: List both aws:PrincipalOrgID and aws:SourceOrgID in the same StringNotEqualsIfExists block (an OR), so the deny fires only when both are foreign.

3. Console S3 access is denied after you add the network (aws:SourceVpce) RCP. Root cause: Console traffic never traverses a VPC endpoint, so aws:SourceVpce is absent — and your StringNotEqualsIfExists on aws:SourceVpce still fired because you forgot the Null guard, or the service exemptions. Confirm: Denied events originate from console sessions with no vpcEndpointId in the CloudTrail record. Fix: Add Null: {"aws:SourceVpce": "false"} (apply only when a VPCE was actually used) plus BoolIfExists exemptions for aws:PrincipalIsAWSService and aws:ViaAWSService. The network deny must fail closed only for traffic you expect to be endpoint-bound.

4. A SaaS vendor role can no longer assume into your account. Root cause: The vendor assumes from their account, so aws:PrincipalOrgID is foreign and the identity RCP’s sts:AssumeRole deny catches it — correctly, but you need this one exception. Confirm: CloudTrail AssumeRole with AccessDenied, source = the vendor’s account ID. Fix: Carve the specific role out with ArnNotLikeIfExists on aws:PrincipalArn, and require sts:ExternalId on that role’s trust policy so the carve-out is confused-deputy-safe. Never widen the org check.

5. The RCP attaches cleanly but denies nothing — the perimeter does not hold. Root cause: Either it is attached to an OU that does not contain the resources, or someone detached the implicit RCPFullAWSAccess baseline (or a typo means the Deny condition never matches). Confirm: aws organizations list-policies-for-target --filter RESOURCE_CONTROL_POLICY shows where it is attached; confirm RCPFullAWSAccess is still present alongside it. Fix: Attach at the OU/account that owns the resources; never detach the baseline; re-test with a deliberate foreign-style call.

6. Two foreign-org checks in separate statements over-block legitimate traffic. Root cause: You split aws:PrincipalOrgID and aws:SourceOrgID (or two partner org IDs) into separate Deny statements. Each statement denies independently, so the effective logic is “deny if foreign-by-principal OR deny if foreign-by-source” — an AND-of-denies that blocks service traffic the OR would have allowed. Confirm: Read the policy; you have two Deny blocks each with one key. Fix: Combine the keys into a single StringNotEqualsIfExists block — multiple keys in one block are an OR, which is what you want.

Error and API-exception reference

The exact error strings you will see — at the Organizations control plane when authoring/attaching, and at the data plane when the perimeter fires — with what each means and the move it calls for:

Error / exception Where it surfaces What it means Move
PolicyTypeNotEnabledException create-policy / attach-policy The policy type is not enabled on the root enable-policy-type RESOURCE_CONTROL_POLICY
PolicyTypeAlreadyEnabledException enable-policy-type Type already on (benign) Ignore; proceed to attach
MalformedPolicyDocumentException create-policy JSON invalid or condition operator unknown Validate JSON; check operator spelling
PolicyNotAttachedException detach-policy Detaching where it was never attached Confirm target with list-policies-for-target
DuplicatePolicyAttachmentException attach-policy Already attached to that target No action needed
ConstraintViolationException (max policies) attach-policy >5 policies of a type on the entity Consolidate statements into fewer policies
PolicyDocumentSizeLimitExceeded create/update-policy Body > 5,120 chars (RCP/SCP) Trim whitespace; merge Sids; split logically
AccessDenied (S3 data plane) Caller / CloudTrail RCP denied a foreign or non-VPCE caller Expected (perimeter working) or allowlist
AccessDeniedException (STS) Caller / CloudTrail RCP denied a foreign AssumeRole Carve out by ARN + ExternalId if legitimate
AccessDenied with userIdentity.type=AWSService CloudTrail Service call false-denied (missing exemption) Add aws:SourceOrgID / PrincipalIsAWSService
UnauthorizedOperation (EC2, no message) EC2 API Declarative block with no exception_message Add exception_message.value for clarity
Blocked by org data-perimeter... (your text) EC2 API Declarative exception_message surfaced Working as intended; route user to your channel

The CLI command reference

The commands you will reach for across the lifecycle — enable, author, attach, verify, roll back — in one place:

Goal Command
Find the root ID aws organizations list-roots --query 'Roots[0].Id' --output text
Enable RCP type aws organizations enable-policy-type --root-id $ROOT_ID --policy-type RESOURCE_CONTROL_POLICY
Enable declarative EC2 type aws organizations enable-policy-type --root-id $ROOT_ID --policy-type DECLARATIVE_POLICY_EC2
Which types are enabled aws organizations describe-organization --query 'Organization.AvailablePolicyTypes'
Create a policy aws organizations create-policy --name N --type RESOURCE_CONTROL_POLICY --content file://p.json
Attach to an OU aws organizations attach-policy --policy-id $ID --target-id $OU_ID
What is attached to a target aws organizations list-policies-for-target --target-id $OU_ID --filter RESOURCE_CONTROL_POLICY
What a policy is attached to aws organizations list-targets-for-policy --policy-id $ID
Roll back (detach) aws organizations detach-policy --policy-id $ID --target-id $OU_ID
Verify IMDSv2 default aws ec2 get-instance-metadata-defaults --region <r>
Verify AMI public-access block aws ec2 get-image-block-public-access-state --region <r>
Verify snapshot public-access block aws ec2 get-snapshot-block-public-access-state --region <r>
Stand up org Access Analyzer aws accessanalyzer create-analyzer --analyzer-name org-perimeter --type ORGANIZATION

Best practices

Crisp, production-grade rules distilled from real rollouts:

Security notes

The perimeter is the security control, but it has its own security properties to get right:

Concern Risk if ignored Mitigation
Least privilege on the policy editor Anyone who can edit RCPs can open the perimeter Restrict organizations:CreatePolicy/AttachPolicy to a small admin role; require change review
Break-glass abuse An exempt mgmt-account role is a high-value target Tightly scope, MFA-gate, alert on every assumption, rotate
Encryption at rest KMS in scope means key policy + RCP interact Ensure KMS key policies also restrict to aws:PrincipalOrgID; RCP Deny overrides key-policy Allow
Network isolation Public S3/PaaS endpoints bypass the VPC leg Force traffic through interface/gateway endpoints; deny non-VPCE data-plane calls
Identity for service calls Over-broad service exemption is a hole Scope aws:SourceOrgID to your org only; do not blanket-allow all service principals
Presigned URL lifetime Long-lived presigned URLs widen the window Keep signature TTLs short; the network RCP catches redemption regardless
Audit of exceptions Stale vendor carve-outs become forgotten holes Quarterly review; remove on vendor offboarding; Access Analyzer continuous check
CloudTrail integrity The perimeter’s evidence base must be trustworthy Org-trail to a locked log-archive account; KMS-encrypt; vault-lock the bucket

The defense-in-depth layering, from the credential outward — each layer assumes the one before it can fail:

Layer Assumes Adds
IAM least privilege Nothing Minimal grant per principal
SCP Principal may be over-permissioned Your principals can’t reach foreign resources
RCP A credential may leak Foreign/anon callers can’t reach your resources
Network (VPCE) RCP A valid key may be stolen Only endpoint-bound traffic touches data
Declarative EC2 Misconfig will happen IMDSv2 + no public AMIs/snapshots, fleet-wide
Access Analyzer All the above may have gaps Continuous proof of external exposure

The perimeter as an attacker would test it — each attack, the layer that catches it, and the single signal that proves the catch:

Attack attempt Caught by Proof signal
Foreign principal reads your bucket directly Identity RCP AccessDenied, non-org userIdentity.accountId
Anonymous presigned-URL read from the internet Network RCP AccessDenied, no vpcEndpointId
Leaked org key reused from a coffee-shop IP Network RCP AccessDenied, aws:SourceVpce absent
Your compromised role writes to attacker’s bucket Resource SCP AccessDenied on PutObject, foreign ResourceOrgID
Foreign principal assumes one of your roles Identity RCP (sts:AssumeRole) AccessDeniedException, foreign source account
SSRF pulls IMDSv1 credentials from an instance Declarative (http_tokens:required) IMDSv1 request rejected; IMDSv2 token required
Accidental public AMI/snapshot share Declarative (block-public-access) ModifyImageAttribute public-add blocked
Decrypt your data with a leaked KMS grant Identity RCP (kms:Decrypt) AccessDenied, foreign aws:PrincipalOrgID

Cost & sizing

The good news on cost: the perimeter itself is free. AWS Organizations, SCPs, RCPs, declarative policies, OUs, and attachments carry no charge. What costs money is the evidence and verification machinery around them — and even that is modest. Here is what actually drives the bill:

Cost driver What it is Rough cost How to right-size
RCP / SCP / declarative policies The guardrails themselves Free No action — there is no per-policy charge
CloudTrail management events First copy of mgmt events Free (first trail) Use the org trail; don’t duplicate
CloudTrail data events (S3/object-level) Per-object S3/Lambda events for the pre-flight ~$0.10 / 100k events (~₹8) Scope data events to perimeter buckets only
CloudTrail Lake event data store Storage + scanned data for pre-flight queries ~$2.50/GB ingested (~₹210); query per-GB scanned Set retention sensibly; partition queries by date
Athena (if querying via S3 instead) Per-TB scanned over CloudTrail S3 logs ~$5/TB scanned (~₹420) Partition by date; query narrow windows
IAM Access Analyzer (external access) Continuous external-access analysis Free Run one org-level analyzer
IAM Access Analyzer unused access Optional unused-access analyzer per-resource monthly Enable selectively if you also want unused-access
VPC interface endpoints (network leg) Per-AZ-hour + per-GB for interface endpoints ~$0.01/AZ-hr + ~$0.01/GB (~₹0.85 each) Use gateway endpoints for S3/DynamoDB (free)

A sizing rule of thumb: for an org of ~180 accounts, the dominant line is the CloudTrail Lake / Athena pre-flight, and even a 90-day, all-accounts scan of S3/STS/KMS/Secrets management events is typically single-digit dollars per query if you partition by date. The gateway VPC endpoint for S3 is free — prefer it over an interface endpoint for the network leg wherever your topology allows, and reserve paid interface endpoints for services that require them. The verification you should never cut to save cost is the per-wave pre-flight; a single missed foreign relationship that breaks production costs far more than the query.

Free-tier and always-free notes: Organizations and its policies are always free; the first CloudTrail trail of management events is free; IAM Access Analyzer external-access findings are free; gateway VPC endpoints are free. The only reliably-recurring spend is data-event capture and Lake/Athena scanning, both of which you control by scoping and partitioning.

Interview & exam questions

Mapped to AWS Certified Security – Specialty (SCS-C02) and Solutions Architect – Professional (SAP-C02), where data-perimeter and Organizations governance show up heavily.

1. What is the fundamental difference between an SCP and an RCP? An SCP evaluates against the principal making the request and can only constrain principals inside your organization. An RCP evaluates against the resource being acted on, for any caller — including principals from foreign orgs and anonymous callers. SCPs are the identity-side guardrail; RCPs are the resource-side guardrail. Both are deny-only and never grant.

2. A leaked, valid IAM key is used to read an S3 bucket via a presigned URL. Which control stops it, and why don’t SCPs? SCPs don’t, because the key is a legitimate org principal and the read is authorized — there is nothing on the identity side to deny. The network-perimeter RCP (aws:SourceVpce failing closed) stops it: the presigned URL’s redemption comes from outside any approved VPC endpoint, so the resource-side deny fires regardless of the valid signer.

3. Which services does RCP support at GA, and why those specifically? S3, STS, SQS, KMS, and Secrets Manager — the set of services that hold or broker data and credentials. Locking the resource side of exactly these closes the realistic exfiltration paths (object data, role assumption, queues, keys, secrets).

4. Why must you use StringNotEqualsIfExists rather than StringNotEquals in a perimeter deny? Because many legitimate requests arrive without the condition key (e.g., console calls with no aws:PrincipalOrgID). Plain StringNotEquals treats “key absent” as “not equal to your value,” firing the deny on legitimate traffic. IfExists skips the condition when the key is absent, so the deny only fires when the key is present and mismatched.

5. Your S3 replication breaks the moment the identity RCP attaches. What did you forget? The aws:SourceOrgID exemption (and/or aws:PrincipalIsAWSService). When an AWS service acts on a resource you own, the call carries aws:SourceOrgID (your org) but may lack aws:PrincipalOrgID. List both keys in the same StringNotEqualsIfExists block so the deny only fires when both are foreign.

6. What is a Declarative Policy and how does it differ from an SCP deny for enforcing IMDSv2? A declarative policy sets a durable service configuration baseline (e.g., http_tokens: required) that EC2 itself enforces and reports, and it persists even as AWS ships new launch APIs. An SCP deny on ec2:RunInstances with a metadata condition only blocks the actions you enumerate; declarative policy sets the default and survives new APIs, plus surfaces an exception_message.

7. Why is the management account special in a data perimeter? Neither SCPs nor RCPs apply to the management account’s own principals or resources, even when attached at the root. So you must keep all data and workloads out of it — and you can use a management-account role as a break-glass path that is inherently exempt from the perimeter.

8. How do you allow a third-party SaaS vendor to assume a role without weakening the RCP? Scope the role’s trust policy to the vendor’s account plus a sts:ExternalId, then carve that specific role out of the RCP with ArnNotLikeIfExists on aws:PrincipalArn. The org check stays intact for everyone else; the vendor is the only foreign ARN allowed through, and ExternalId makes it confused-deputy-safe.

9. Why do you stage attachment sandbox → non-prod → prod → root rather than attaching at root first? Org policy inheritance is cumulative and there is no audit-only mode. A deny attached at the root applies everywhere immediately; if it is wrong it breaks the whole org. Attaching at a leaf sandbox OU first lets you break things safely, gather evidence, and promote only after a soak — root is the last step, not the first.

10. How do you find what a perimeter would break before attaching it? Run a CloudTrail Lake (or Athena) query over the last ~90 days for access to your resources by principals whose account ID is not in your org (excluding AWS service calls). Every row is a foreign-principal relationship you must explicitly allowlist before the deny goes live. Zero unexpected rows in the target scope is your go signal.

11. What does aws:ResourceOrgID enforce, and which policy type uses it? It identifies the org that owns the target resource. You use it in an SCP (identity side) to stop your principals from writing to or reading resources in a foreign org — the exfiltration-outward direction. An RCP cannot enforce it because the foreign resource is outside your policy’s reach.

12. If an RCP Deny and an S3 bucket policy Allow conflict, which wins? The Deny wins. RCPs are evaluated alongside resource-based policies, and an explicit Deny in any applicable policy overrides any Allow. This is precisely why an RCP can close a hole that a permissive bucket policy left open.

Quick check

  1. SCPs evaluate against the ______ side of a request; RCPs evaluate against the ______ side.
  2. Name the five services RCP supports at GA.
  3. Why must perimeter denies use ...IfExists operators?
  4. Which two condition keys exempt legitimate AWS-service-to-service traffic in the identity RCP?
  5. Which perimeter leg stops a valid leaked key replayed from the open internet, and which key does it use?

Answers

  1. Identity (principal / caller) side for SCPs; resource (target) side for RCPs.
  2. S3, STS, SQS, KMS, and Secrets Manager.
  3. Because many legitimate requests lack the condition key (e.g., console calls with no aws:PrincipalOrgID); without IfExists, “key absent” is treated as a mismatch and the deny fires on legitimate traffic, locking out whole OUs.
  4. aws:SourceOrgID (service acting on a resource you own) and aws:PrincipalIsAWSService (service-linked / log-delivery calls carrying no org key).
  5. The network-perimeter RCP, using aws:SourceVpce (with a Null/IfExists guard) — it fails closed when the request did not traverse an approved VPC endpoint, even though the leaked key’s org ID passes.

Glossary

Next steps

awsorganizationsrcpdata-perimeteriamgovernance
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments