June 1, 2026

Building an AWS Data Perimeter, Part 2: SCPs, RCPs, and Proving the Perimeter Holds

Attaching SCPs and RCPs to an AWS Organization, enforcing the new aws:VpceOrgID condition, and running denial tests against an external account with CloudTrail evidence.

Building an AWS Data Perimeter, Part 2: SCPs, RCPs, and Proving the Perimeter Holds

Part 1 built the substrate: org structure, VPC with private endpoints, S3 bucket, KMS CMK, and an EC2 instance in a subnet with no internet gateway. Nothing was denied yet. This part is where the denials start.

The goal: attach a Service Control Policy, a Resource Control Policy, and VPC endpoint policies to the Workloads OU, then run tests from an external AWS account to prove the perimeter fires. The headline test is kms:DescribeKey from an external account returning with an explicit deny in a resource control policy. Getting there required understanding how RCPs are authored and how AWS surfaces error messages to external callers.

Phase 3: Attaching the Controls

The SCP: Identity Perimeter

The SCP denies internal principals from acting on resources outside the org, via the condition aws:ResourceOrgID != aws:PrincipalOrgID.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyActionsOnResourcesOutsideOrg",
    "Effect": "Deny",
    "Action": ["s3:*", "sts:*", "kms:*"],
    "Resource": "*",
    "Condition": {
      "StringNotEqualsIfExists": {
        "aws:ResourceOrgID": "${aws:PrincipalOrgID}"
      },
      "BoolIfExists": {
        "aws:PrincipalIsAWSService": "false"
      },
      "ArnNotLikeIfExists": {
        "aws:PrincipalArn": ["arn:aws:iam::*:role/aws-service-role/*"]
      }
    }
  }]
}

StringNotEqualsIfExists rather than StringNotEquals matters here. For AWS-managed shared resources, aws:ResourceOrgID is absent from the request context. A strict StringNotEquals would deny access because the missing key counts as a mismatch. IfExists makes the check a no-op when the key is missing, which is the correct behavior.

The BoolIfExists aws:PrincipalIsAWSService false exemption prevents service-linked roles from being blocked. Without it, AWS services making calls on your behalf (EC2 metadata refreshes, Auto Scaling operations) may start failing intermittently.

The RCP: Resource Perimeter

RCPs are the more interesting exercise. They look like SCPs but have two structural differences that catch you immediately:

First: RCPs require "Principal": "*". SCPs are identity guardrails with an implied principal (the account’s IAM identities). RCPs evaluate as resource-based policies and must declare who the statement applies to. Omitting Principal produces a console error: Missing Principal: Add a Principal element to the policy statement.

Second: the service-caller problem needs two statements, not one. A human IAM principal calling S3 directly carries aws:PrincipalOrgID in the request. An AWS service calling S3 on a principal’s behalf (CloudFront fetching from S3, Athena querying a bucket) carries aws:SourceOrgID instead. A single statement cannot check both. StringNotEqualsIfExists can only appear once as a JSON key, and adding it twice produces Duplicate JSON key: StringNotEqualsIfExists.

The canonical pattern from aws-samples/data-perimeter-policy-examples splits this into two statements:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceOrgIdentities",
      "Effect": "Deny",
      "Principal": "*",
      "Action": ["s3:*", "sts:*", "kms:*"],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:PrincipalOrgID": "${aws:ResourceOrgID}"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    },
    {
      "Sid": "EnforceConfusedDeputyProtection",
      "Effect": "Deny",
      "Principal": "*",
      "Action": ["s3:*", "sts:*", "kms:*"],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:SourceOrgID": "${aws:ResourceOrgID}"
        },
        "Bool": {
          "aws:PrincipalIsAWSService": "true"
        },
        "Null": {
          "aws:SourceAccount": "false"
        }
      }
    }
  ]
}

The second statement’s Null: aws:SourceAccount: false is the confused-deputy guard. Some older AWS service integrations do not populate aws:SourceOrgID. Without the Null check, StringNotEqualsIfExists no-ops on those calls and they bypass the deny. The Null check forces the request to carry source context first.

Both policies were attached to the Workloads OU (ou-XXXX-XXXXXXXXXX), leaving FullAWSAccess and RCPFullAWSAccess attached at root.

SCP dp-prod-scp-resource-perimeter attached to the Workloads OU

RCP dp-prod-rcp-trusted-principals attached to the Workloads OU

Those two AWS-managed policies must never be removed. They are the implicit allow everything else denies on top of. Removing either collapses the effective policy to deny-all org-wide.

The VPC Endpoint Policies

The three perimeter endpoints (S3, STS, KMS) had their Full Access policies replaced with org-scoped allow policies.

The S3 gateway endpoint checks both legs (aws:PrincipalOrgID and aws:ResourceOrgID), since traffic destined for a foreign-org bucket should be denied at the endpoint:

{
  "Statement": [{
    "Effect": "Allow", "Principal": "*", "Action": "*", "Resource": "*",
    "Condition": {
      "StringEquals": {
        "aws:PrincipalOrgID": "o-XXXXXXXXXX",
        "aws:ResourceOrgID": "o-XXXXXXXXXX"
      }
    }
  }]
}

The STS interface endpoint only checks aws:PrincipalOrgID. STS has no meaningful aws:ResourceOrgID for AssumeRole, since the target role is evaluated on the destination account’s side, not the endpoint’s.

The aws:VpceOrgID Bucket Policy Condition

The most recent piece of the perimeter. aws:VpceOrgID was released August 2025 and covers an attack vector the RCP alone does not address: an internal principal using a non-org VPC endpoint to reach your bucket.

The RCP blocks external principals. But a compromised internal account could build its own VPC endpoint and route traffic to your bucket through it. The RCP would not fire, since the principal is internal. The bucket policy condition does:

{
  "Sid": "DenyAccessFromNonOrgVpce",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": ["arn:aws:s3:::dp-prod-s3-test-2747XXXXXXXX/*", ...],
  "Condition": {
    "StringNotEqualsIfExists": { "aws:VpceOrgID": "o-XXXXXXXXXX" },
    "BoolIfExists": { "aws:ViaAWSService": "false" },
    "Null": { "aws:VpceOrgID": "false" }
  }
}

The Null: aws:VpceOrgID: false is critical. It scopes the deny to requests that arrived via a VPC endpoint. Without it, the deny would fire on direct internet requests, where aws:VpceOrgID is absent and StringNotEqualsIfExists no-ops. It would also block the cross-account trusted-reader path from workload-b, which has no VPC endpoint.

Bucket policy with DenyAccessFromNonOrgVpce condition using aws:VpceOrgID

Before aws:VpceOrgID, the equivalent control used aws:SourceVpce with a specific endpoint ID, which meant updating every bucket policy each time a new endpoint appeared. The org-level condition covers the entire endpoint fleet in one value.

Positive Path Sanity Check

After attaching the SCP and RCP, the first thing to confirm is that the positive path still works. From Session Manager on the EC2 instance:

$ aws s3 cp s3://dp-prod-s3-test-2747XXXXXXXX/test.txt /tmp/test.txt
$ cat /tmp/test.txt
data-perimeter-test-object

The SCP and RCP saw aws:PrincipalOrgID matching aws:ResourceOrgID and passed the request through. The VPCe policies admitted it because the EC2 instance role is in the org. Three layers active, positive path intact.

Positive path confirmed after SCP and RCP attachment: s3 cp returns test object

Phase 3 also surfaced IAM gaps that Phase 2 had left dormant: kms:Decrypt never granted to the EC2 role, sts:AssumeRole never granted to the EC2 role’s identity policy, and kms:Decrypt never granted to the trusted-reader role’s identity policy. Phase 2 only exercised the upload path; Phase 3 was the first time reads, assumes, and cross-account calls ran end-to-end.

Phase 4: Proving It

The Setup

The external test account (1169XXXXXXXX) has an IAM user (dp-negative-test-user) with explicit Allow on s3:GetObject, sts:AssumeRole, and kms:DescribeKey against the test resources.

External test account IAM user with explicit allow permissions for the negative tests

The non-obvious requirement: the IAM policy on the external user must permit the actions. Otherwise the denial comes from IAM before the RCP ever evaluates, and the test proves nothing about the perimeter.

For each test, the resource-side policy was temporarily opened to the external principal, the test was run, and the policy was restored immediately. The restore is not optional. Leaving a foreign-account principal in a key policy or trust policy is a hygiene failure: if the RCP is detached for any reason, the gap becomes live.

T1 and T2: S3 and STS Return Generic AccessDenied

Both calls were denied, and neither returned the RCP attribution string:

AccessDenied: User: arn:aws:iam::1169XXXXXXXX:user/dp-negative-test-user
is not authorized to perform: s3:GetObject

T1: generic AccessDenied from S3 with no policy attribution, expected for external callers

T2: generic AccessDenied from STS with no policy attribution, expected for external callers

Not a perimeter failure. The AWS S3 troubleshooting documentation is explicit:

“Enhanced access denied messages are returned only for same-account requests or for requests within the same organization in AWS Organizations. Cross-account requests outside of the same organization return a generic Access Denied message.”

AWS deliberately hides the policy structure from external callers. The resource owner sees the full attribution in CloudTrail; the attacker sees only Access Denied. STS follows the same rule. The RCP fired on both, but the evidence is in the CloudTrail data events, not the CLI response.

T3: KMS Returns the Explicit Attribution String

KMS does not apply the same restriction. From the external account, with the external user temporarily added to the KMS key policy:

T3: external account denied kms:DescribeKey with explicit RCP attribution in the error message

AccessDeniedException: User: arn:aws:iam::1169XXXXXXXX:user/dp-negative-test-user
is not authorized to perform: kms:DescribeKey on this resource
with an explicit deny in a resource control policy

That string comes from the same RCP (dp-prod-rcp-trusted-principals) that fired on T1 and T2. KMS surfaces the attribution; S3 and STS suppress it for external callers. The perimeter is active on all three services.

T5: SCP Identity Perimeter: Structural Proof

T5 covers an internal principal writing to a bucket in a foreign org, blocked by the SCP DenyActionsOnResourcesOutsideOrg. The SCP is attached to the Workloads OU, confirmed during Phase 3 validation. A live denial test requires a writable bucket in a foreign-org account. The external account used for T1-T3 lost accessible credentials during this phase, so a live CLI call was not possible.

The structural proof stands: aws organizations list-policies-for-target confirmed dp-prod-scp-resource-perimeter is attached. The SCP condition (StringNotEqualsIfExists: aws:ResourceOrgID != aws:PrincipalOrgID) mirrors the RCP condition that fired on T1-T3 from the identity side. A future run with a persistent external account would close this gap with a live denial and a CloudTrail errorMessage: with an explicit deny in a service control policy.

T6: Anonymous Request, CloudTrail Data Event

An unsigned curl to the test bucket returned HTTP/1.1 403 Forbidden as expected.

T6: anonymous curl returning HTTP 403 Forbidden

The value isn’t the 403 itself, but what the org trail recorded:

eventTime:         2026-05-29T00:39:25Z
eventSource:       s3.amazonaws.com
eventName:         GetObject
userIdentity.type: AWSAccount (anonymous)
errorCode:         AccessDenied

This event came from the S3 data event selector added in Phase 3 (dp-prod-s3-test-bucket-events). Without data events enabled, denied GetObject calls produce no CloudTrail entry. The selector was scoped to the test bucket only, keeping log volume and cost minimal.

Positive Recheck

After the negative tests, Session Manager into the EC2 instance confirmed the positive path:

$ aws sts get-caller-identity
# arn:aws:sts::2747XXXXXXXX:assumed-role/dp-prod-role-ec2-test-client/...
$ aws s3 cp s3://dp-prod-s3-test-2747XXXXXXXX/test.txt /tmp/phase4-positive.txt
$ cat /tmp/phase4-positive.txt
data-perimeter-test-object

Positive recheck after all negative tests: trusted path returns test object

All three perimeter layers active, positive path intact.

Evidence Summary

TestWhat firedCLI outputCloudTrail
T1 (S3 GetObject)RCP EnforceOrgIdentitiesGeneric AccessDenied (S3 hides attribution from external callers)Data event in workload-a trail
T2 (STS AssumeRole)RCP on STSGeneric AccessDeniedIn external account’s own trail
T3 (KMS DescribeKey)RCP on KMSwith an explicit deny in a resource control policyConfirmed directly
T5 (SCP identity)SCP DenyActionsOnResourcesOutsideOrgNot live-tested: SCP attached and confirmed; external credentials unavailablen/a
T6 (anonymous S3)Bucket default-denyHTTP/1.1 403 ForbiddenData event: anonymous AccessDenied
Positive recheckAll controls pass throughdata-perimeter-test-objectn/a

What This Buys You

Before this project, a developer misconfiguring a bucket policy, or an attacker who exfiltrated a long-lived access key from a CI pipeline, could read that bucket. The only defense was the bucket policy on that specific resource.

After: the RCP is org-wide. It cannot be overridden by a bucket policy, an IAM policy, or a resource-based policy. The only way to bypass it: detach it from the Workloads OU in the management account, which requires management-account access and leaves a CloudTrail record. A developer’s misconfigured bucket policy no longer creates a perimeter hole. An attacker with a stolen key from account 1169XXXXXXXX gets AccessDenied before they can enumerate anything.

The aws:VpceOrgID condition closes the VPC endpoint exfiltration path the RCP alone cannot cover. Together, they mean both the “who is calling” question and the “which network path did this arrive on” question have org-level answers, not per-resource answers.

RCPs launched November 2024. aws:VpceOrgID shipped August 2025. Both are recent enough that most production environments have neither deployed. The full project source, policy JSON, and Phase 4 evidence live in the repo.

Back to blog