A data perimeter answers one 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 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 where exfiltration lives.
Resource Control Policies (RCPs), GA since late 2024, close it: org-wide guardrails that attach to the resource side of a request. Pair them with Declarative Policies to durably pin EC2 configuration (IMDSv2, no public AMIs) and you have a perimeter that survives credential theft, console misconfiguration, and the intern who clicks “make public.” This guide builds all three end to end.
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.
1. RCPs vs. SCPs: closing the resource-side gap
The mental model that matters: a request to AWS is authorized only if it survives the intersection of every applicable policy. Add RCPs and the chain looks like this:
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
AND the resource-based policy on the target (no DENY)
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 whole point.
| SCP | RCP | |
|---|---|---|
| Attaches to | Root / OU / account | Root / OU / account |
| Constrains | Your org’s principals | The resource, for any caller |
| Catches external/anonymous principals? | No | Yes |
| Can grant? | No, deny only | No, deny only |
| Affects management account? | No | No |
| Supported services (at GA) | All | S3, STS, SQS, KMS, Secrets Manager |
Two consequences shape everything below:
RCPs start from an implicit
RCPFullAWSAccessbaseline that allows everything, exactly likeFullAWSAccessfor SCPs. You layer deny-with-condition statements on top. If you detach the baseline you break access, 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.
The supported-service list is small but deliberately chosen: it is precisely the set of services that hold or broker data and credentials. Locking S3, STS, SQS, KMS, and Secrets Manager covers the realistic exfiltration paths.
2. The three trust dimensions
A data perimeter is the cross-product of three statements that should all be true for every legitimate request:
- Trusted identities — only principals from my org touch my resources (
aws:PrincipalOrgID). - Trusted resources — my principals only touch resources in my org (
aws:ResourceOrgID, enforced via SCP). - Trusted networks — requests only come from my expected networks (
aws:SourceVpc,aws:SourceVpce,aws:SourceIp).
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 condition keys you will lean on:
| Key | Meaning | Where it shines |
|---|---|---|
aws:PrincipalOrgID |
Org ID of the calling principal | RCP: reject foreign principals |
aws:SourceOrgID |
Org ID of the resource owner initiating an AWS-service call | RCP: scope service-principal access |
aws:ResourceOrgID |
Org ID owning the targeted resource | SCP: stop writes to foreign resources |
aws:PrincipalIsAWSService |
Request made by an AWS service principal | RCP: exempt service-to-service calls |
aws:SourceVpce |
VPC endpoint the request traversed | Network trust |
3. 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):
# Find the root ID
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
# Enable the Resource Control Policy type
aws organizations enable-policy-type \
--root-id "$ROOT_ID" \
--policy-type RESOURCE_CONTROL_POLICY
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:
StringNotEqualsIfExists—IfExistsskips the condition when the key is absent rather than firing a stray deny. Listingaws:PrincipalOrgIDandaws:SourceOrgIDin oneStringNotEquals*block is an OR: the deny fires only if the principal is foreign and the source-org does not match.aws:SourceOrgIDcovers a service principal acting under a resource you own (S3 replication, CloudTrail writes to your bucket).aws:PrincipalIsAWSServicefalse — belt-and-suspenders for service-linked calls that carry no org key at all (log delivery, service-linked roles). Without it you deny legitimate AWS-internal traffic.
Attach it. Start at a sandbox OU (covered in section 6), not the root:
aws organizations attach-policy \
--policy-id p-rcp0123456789 \
--target-id ou-root-sandbox01
sts:AssumeRolein the RCP is the highest-leverage line. 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.
4. 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 resource is foreign and outside your policy’s reach; only an identity-side SCP on your principals can.
{
"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.
5. 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. The network dimension catches it — that stolen key is being used from the open internet, not from inside your VPC.
Enforce this 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 (aws:SourceVpc, aws:SourceIp) to your topology; the principle is to fail closed only for traffic you genuinely expect to be endpoint-bound.
6. Durable EC2 controls with Declarative Policies
SCPs and RCPs deny actions. Declarative Policies are different: 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.
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:
http_tokens: requiredforces 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_accessandsnapshot_block_public_accessmake accidental public AMI/snapshot sharing structurally impossible — a whole class of data-leak incident closed at the service layer.exception_messageis surfaced to the user on a block, so the failure is self-documenting instead of a crypticUnauthorizedOperation.
Attach to the sandbox OU first, verify, then promote.
7. Staged rollout without locking yourself out
This is where teams either succeed or page themselves at 2 a.m. The discipline:
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
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.
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.
8. Exceptions: AWS services, SaaS roles, cross-org sharing
A real perimeter has documented holes. Manage them as named exceptions, not by weakening the baseline.
AWS service principals — already handled by the aws:PrincipalIsAWSService / aws:SourceOrgID conditions in section 3. 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 it precisely with the vendor’s account and the sts:ExternalId they issued you, on the role trust policy, and let the RCP’s org check exempt that path via a tightly-bounded principal:
{
"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.
Enterprise scenario
A fintech platform team I worked with ran ~180 accounts with a clean SCP layer, but a red team exfiltrated a customer dataset using a leaked CI/CD access key: the key generated an S3 presigned URL and the data was pulled from a coffee-shop IP to an attacker-controlled bucket. SCPs did nothing — the key was a legitimate org principal and the read was authorized.
The constraint was brutal: they could not break dozens of legitimate cross-account data flows (a data lake read by twelve consumer accounts, vendor backup roles, S3 replication to a DR account in a separate org for regulatory isolation). A blunt aws:PrincipalOrgID deny would have taken down the DR replication and three SaaS integrations on day one.
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, and the presigned-URL exfil path was killed by the network RCP — presigned URLs carry the signer’s identity but the redemption came from outside any VPCE, 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 from section 7 surfaced exactly four foreign-principal relationships; all four were allowlisted before go-live, and the rollout caused zero production incidents. The same leaked key, replayed post-deployment, returned AccessDenied.
Verify
Prove the perimeter actually holds — do not trust that it attached.
# 1. Confirm the policy types are enabled on the root
aws organizations describe-organization \
--query 'Organization.AvailablePolicyTypes'
# 2. Confirm the RCP is attached where you expect
aws organizations list-policies-for-target \
--target-id ou-root-sandbox01 \
--filter RESOURCE_CONTROL_POLICY
# 3. Negative test: assume a sandbox role, then read a bucket
# from a principal/network the perimeter should reject.
aws s3 cp s3://protected-bucket/canary.txt - # expect: AccessDenied
// CloudTrail Lake: did any blocked-by-perimeter denies fire? (post-rollout)
SELECT eventTime, eventName, userIdentity.arn, errorCode, sourceIPAddress
FROM cloudtrail_event_data_store
WHERE errorCode IN ('AccessDenied', 'AccessDeniedException')
AND eventSource = 's3.amazonaws.com'
AND eventTime > timestamp '2026-06-08 00:00:00'
ORDER BY eventTime DESC
LIMIT 100;
Then stand up IAM Access Analyzer with the organization as the zone of trust and let it continuously flag anything that breaches the perimeter — public/cross-account grants on buckets, roles, keys, secrets, and queues:
aws accessanalyzer create-analyzer \
--analyzer-name org-data-perimeter \
--type ORGANIZATION
# List external-access findings (resources reachable from outside the org)
aws accessanalyzer list-findings-v2 \
--analyzer-arn "$ANALYZER_ARN" \
--filter '{"status":{"eq":["ACTIVE"]}}'
Every active external-access finding is, by definition, a tear in the perimeter or a documented exception. Drive that list to zero-or-justified and wire it into a weekly review. Use IfExists consistently and you will get far fewer false denies; forget it on a single key and you will break console access for a whole OU.