AWS Security Architecture

Tony St. Pierre
  • AWS
  • Cloud Security
  • IAM
  • AWS Organizations
  • Resource Control Policies
  • Service Control Policies
  • Data Perimeters

Beyond the SCP-Only Perimeter: Architecting AWS Data Perimeters with Resource Control Policies

Most AWS security programs do not start with data. They start with identity.

Centralize the accounts. Add Service Control Policies. Enforce MFA. Automate least-privilege reviews. Push policy checks into CI/CD. Over time, the identity side starts to look mature.

All of that matters.

But it still leaves a gap.

One overly permissive resource policy can still create a direct path to sensitive data. A bucket policy can trust the wrong account. A KMS key policy can allow unintended cross-account use. A queue policy can get widened for a migration and never tightened again.

That is where Resource Control Policies become interesting.

The stakes are not theoretical. IBM’s 2025 Cost of a Data Breach Report placed the global average breach cost at USD 4.44 million, the U.S. average at USD 10.22 million, and the average breach lifecycle at 241 days.

Verizon’s 2026 Data Breach Investigations Report points in the same direction from a different angle: 31% of breaches now start with software vulnerabilities, 48% involve ransomware, and the same old failure modes still keep showing up — human error, social engineering, phishing, stolen credentials, and exposed vulnerabilities.

The numbers explain why this matters. The architecture explains where the old model breaks.

For AWS architects, the lesson is uncomfortable: centralized identity governance is necessary, but it is not the same thing as a data perimeter.

SCPs are still essential. They just solve a different problem.


The Illusion of the SCP-Only Perimeter

Service Control Policies define the maximum permissions available to IAM users and roles in member accounts of an AWS Organization. They do not grant access. They narrow the scope of what identity-based and resource-based policies can ultimately allow.

That model works when the principal making the request belongs to your organization.

It does not solve the inbound resource problem.

Consider a simple cross-account scenario. Account A is inside your AWS Organization and owns an S3 bucket. Account B is outside your organization. If Account A's bucket policy accidentally grants Account B access to s3:GetObject, your SCPs do not govern Account B's principal. The request is coming from outside your identity boundary.

Without a resource-side organizational guardrail, the bucket policy becomes the perimeter.

That does not hold in enterprise environments with thousands of buckets, queues, keys, secrets, tables, repositories, logs, and console access paths. Resource-based policies are necessary, but they are decentralized. They drift. They get patched during incidents. They get widened during migrations. They get copied from old examples.

At scale, demanding every resource policy to be perfect is not architecture. It is wishful thinking.

AWS documents the distinction directly: use SCPs when you need to limit IAM principals within member accounts; use RCPs when you need to restrict external IAM principals making requests to resources in those member accounts.

Miss that distinction, and the rest of the architecture gets blurry.

SCPs govern what identities in your organization are allowed to do.

RCPs govern what access your resources are allowed to accept.


What RCPs Actually Do

A Resource Control Policy is an AWS Organizations policy type that can be attached to the organization root, an organizational unit, or an individual member account.

The important part is not where the policy is attached. It is what the policy governs.

Instead of controlling the maximum permissions available to principals, an RCP controls the maximum permissions available to resources in those accounts.

RCPs do not grant access. They define a resource-side permissions guardrail. Effective access continues to be the intersection of RCPs, SCPs, identity-based policies, resource-based policies, permissions boundaries, session policies, VPC endpoint policies, and service-specific controls.

A bucket policy can still share data intentionally. It just cannot override the organization's hard boundary.

AWS launched RCPs on November 13, 2024, with support for Amazon S3, AWS Security Token Service, AWS Key Management Service, Amazon SQS, and AWS Secrets Manager. AWS has expanded support since then. Current AWS Organizations documentation lists support across a wider set of services, including S3, STS, KMS, SQS, Secrets Manager, Cognito, CloudWatch Logs, DynamoDB, AppConfig, AppStream, EC2 Auto Scaling, CodeBuild, CodeCommit, Comprehend, Comprehend Medical, DAX, ECR, AWS Health, Kinesis Video Streams, OpenSearch Serverless, AWS Sign-In, AWS Support, Textract, Transcribe, and Translate.

The timeline matters because RCPs are not universal. They apply only to supported services and supported resource authorizations. Notable expansions include ECR and OpenSearch Serverless on June 19, 2025; Cognito and CloudWatch Logs on January 22, 2026; DynamoDB on February 12, 2026; and AWS Sign-In for Management Console access on June 16, 2026.

The exact list will keep changing. Treat the current AWS Organizations RCP support list and the Service Authorization Reference as the source of truth before designing a perimeter. Do not assume a condition key, action prefix, or resource type works just because it looks similar to a supported service.


The Authorization Model: Where RCPs Fit

AWS authorization starts with implicit deny. A request must be allowed by the relevant permission layer and must not be explicitly denied by any applicable policy. An explicit deny wins.

RCPs participate in that model as resource-side guardrails. A request to a governed resource succeeds only if all of the following are true:

  1. The applicable RCP layer allows the request through and contains no explicit deny that applies.
  2. The applicable SCP layer allows the request through and contains no explicit deny that applies.
  3. The principal has permission through an identity-based policy, a resource-based policy, or another valid authorization path.
  4. No other applicable permission boundary, session policy, VPC endpoint policy, or service-specific control blocks the request.

This is where the failure mode changes.

A bucket policy can still permit a data-processing account. A KMS key policy can still delegate administration. A queue policy can still permit an event producer. But the organization now defines conditions that local policies cannot violate.


Pattern 1: Enforce a Trusted Identity Perimeter

The first policy most teams will want is simple: keep supported resources in member accounts from accepting access by principals outside the AWS Organization.

This pattern uses aws:PrincipalOrgID to deny access when the calling principal is not a member of the organization. It also exempts AWS service principals with aws:PrincipalIsAWSService, because AWS services frequently access customer resources on behalf of customers and may not carry the same organization context as an IAM principal.

The example below is broad to show how the pattern scales. It is a sample, not a template. Include only the supported action prefixes that match your perimeter objective. Test the policy in a sandbox OU.

Implementation note: Replace every placeholder before using these examples: o-xxxxxxxxxx, account IDs, VPC IDs, VPC endpoint IDs, CIDR ranges, Regions, and break-glass role ARNs. Treat the JSON as deployment patterns, not copy-paste production policy.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceTrustedIdentityPerimeter",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "s3:*",
        "kms:*",
        "sqs:*",
        "secretsmanager:*",
        "dynamodb:*",
        "logs:*",
        "cognito-idp:*",
        "cognito-identity:*",
        "ecr:*",
        "aoss:*",
        "codebuild:*",
        "codecommit:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:PrincipalOrgID": "o-xxxxxxxxxx"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    }
  ]
}

This is the core perimeter move. If a local S3 bucket policy allows an external AWS account, this RCP denies the request before the bucket policy becomes the final authority.

This is not a paste-and-pray policy. Some organizations need intentional third-party access, vendor integrations, or cross-organization sharing. Design those paths explicitly. Do not leave them to accidental resource-policy allowance.


Pattern 2: Protect the Cryptographic Boundary

Storage controls are not enough if the key boundary is weak.

KMS is one of the highest-value services to govern with RCPs because key usage can become the hidden bypass in a data protection strategy. If an attacker cannot read an object but can use the customer-managed key to decrypt related data elsewhere, the perimeter is weak.

Protect not just storage, but the cryptographic capabilities tied to that storage.

AWS KMS documentation confirms that RCPs can be used to manage permissions for customer-managed KMS keys. It also warns that RCPs do not apply to AWS-managed KMS keys.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyKMSUseFromPrincipalsOutsideOrg",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "kms:Decrypt",
        "kms:Encrypt",
        "kms:ReEncrypt*",
        "kms:GenerateDataKey*",
        "kms:CreateGrant"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:PrincipalOrgID": "o-xxxxxxxxxx"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    }
  ]
}

This prevents a customer-managed key from being used by principals outside the organization, even if a key policy is accidentally broadened.

Two details are easy to miss.

First, this does not affect AWS-managed keys, including service-owned default keys. If your data perimeter depends on RCP-enforced KMS controls, use customer-managed keys where those controls matter.

Second, kms:RetireGrant is not effective in an RCP, even if the RCP uses kms:*. Do not ignore RCPs for this reason. Pair them with identity-side controls and careful KMS grant administration. For identities you control, use SCPs or IAM identity policies to restrict grant-retirement, grant-revocation, key-policy, key-disablement, and key-deletion actions. Constrain who can create grants and which grant operations are allowed. Reserve those capabilities for automation and break-glass roles.

A complementary SCP pattern looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyKMSGrantAdminExceptApprovedRoles",
      "Effect": "Deny",
      "Action": [
        "kms:RetireGrant",
        "kms:RevokeGrant",
        "kms:PutKeyPolicy",
        "kms:ScheduleKeyDeletion",
        "kms:DisableKey"
      ],
      "Resource": "*",
      "Condition": {
        "ArnNotLike": {
          "aws:PrincipalArn": [
            "arn:aws:iam::*:role/SecurityAutomationRole",
            "arn:aws:iam::*:role/BreakGlassRole"
          ]
        }
      }
    }
  ]
}

That SCP is not an RCP. It belongs on the identity side of the architecture. The point is to close the operational gap that RCPs deliberately avoid covering.


Pattern 3: Control AWS Service Access Without Breaking Service Integrations

This is where teams often overcorrect.

AWS services frequently access your resources on your behalf. CloudTrail writing logs to S3, a service using a KMS key, or an event flow invoking another service may involve AWS service principals. If you block those calls blindly, you will break native integrations.

The perimeter should block hostile access, not break AWS itself.

Do not allow every AWS service principal by default. Require AWS service access to originate from your organization when the request includes source-account context.

AWS data perimeter guidance and KMS examples use condition keys such as aws:SourceOrgID, aws:PrincipalIsAWSService, and aws:SourceAccount for this confused-deputy mitigation pattern.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAWSServiceAccessUnlessRequestOriginatesFromOrg",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "kms:Decrypt",
        "kms:Encrypt",
        "kms:ReEncrypt*",
        "kms:GenerateDataKey*",
        "kms:CreateGrant"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:SourceOrgID": "o-xxxxxxxxxx"
        },
        "Bool": {
          "aws:PrincipalIsAWSService": "true"
        },
        "Null": {
          "aws:SourceAccount": "false"
        }
      }
    }
  ]
}

This says: if the caller is an AWS service principal and the request includes source-account context, deny the request unless the source account belongs to the organization.

That preserves legitimate AWS service integrations while reducing confused-deputy exposure.

RCPs do not restrict service-linked roles. AWS services rely on those roles for required operations. Treat this as a scope boundary.


Pattern 4: Enforce Transport Security Without Pretending HTTPS Means TLS 1.3

aws:SecureTransport is not a TLS-version control.

It is a boolean condition key. It checks whether a request was sent over HTTPS/TLS. It does not evaluate the negotiated TLS version.

AWS S3 documentation separates these controls. For HTTPS-only enforcement, use aws:SecureTransport. For S3-specific TLS-version enforcement, use s3:TlsVersion.

For HTTPS-only enforcement:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransportForS3",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    }
  ]
}

That denies plaintext HTTP requests. It does not force TLS 1.3.

For S3-specific minimum TLS-version enforcement, use s3:TlsVersion:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyS3RequestsBelowTLS13",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "NumericLessThan": {
          "s3:TlsVersion": "1.3"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    }
  ]
}

This example is S3-specific. Do not generalize s3:TlsVersion across all AWS services. Check endpoint coverage before enforcing TLS 1.3. AWS documents TLS 1.3 support for S3 endpoints except AWS PrivateLink for Amazon S3 and S3 Multi-Region Access Points.

Watch for operational traps. AWS redacts certain network-specific context keys during service-to-service calls, including s3:TlsVersion, aws:SecureTransport, aws:SourceIp, and aws:VpcSourceIp. If you write broad deny statements using these keys and do not exempt AWS service principals, you can block AWS services from accessing resources on your behalf.

The control is useful. The lazy version is dangerous.


Pattern 5: Restrict AWS Management Console Sign-In by Network

Console access deserves extra caution because lockout risk is real.

AWS Sign-In added support for resource-based policies and RCPs for the AWS Management Console on June 16, 2026. This is a major expansion because it moves part of console access governance into the same organization-wide policy model.

This control is narrower than it first appears, and that matters. AWS Sign-In RCPs apply to console sign-in flows, including direct console sign-in, IAM Identity Center, SAML/OIDC federation, and applications integrated with AWS Sign-In. They do not apply to programmatic access using access keys or SigV4-signed API calls.

AWS Sign-In evaluates policies in two phases:

  • Pre-authentication: signin:Authenticate
  • Post-authentication: signin:AuthorizeOAuth2Access and signin:CreateOAuth2Token

The condition keys differ by phase. During pre-authentication, use signin:PrincipalArn for principal-based exceptions. During post-authentication, use aws:PrincipalArn.

The subtle part is network-key handling. The AWS Sign-In documentation states that a single request contains either aws:SourceIp for public network access or aws:SourceVpc for VPC endpoint access, not both. IAM condition blocks also evaluate multiple condition operators and context keys as a logical AND. For mixed public IP and VPC endpoint deny-unless policies, use IfExists on the network keys, or split the public and VPC paths into separate statements.

The example below uses the IfExists safe path. For easier auditing, use separate statements for public-network access and VPC endpoint access when policy size allows.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceConsoleNetworkPreAuth",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "signin:Authenticate",
      "Resource": "*",
      "Condition": {
        "ArnNotEquals": {
          "signin:PrincipalArn": [
            "arn:aws:iam::123456789012:role/BreakGlassRole"
          ]
        },
        "NotIpAddressIfExists": {
          "aws:SourceIp": [
            "203.0.113.0/24"
          ]
        },
        "StringNotEqualsIfExists": {
          "aws:SourceVpc": "vpc-0abc123def456789"
        }
      }
    },
    {
      "Sid": "EnforceConsoleNetworkPostAuth",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "signin:AuthorizeOAuth2Access",
        "signin:CreateOAuth2Token"
      ],
      "Resource": "*",
      "Condition": {
        "ArnNotEquals": {
          "aws:PrincipalArn": [
            "arn:aws:iam::123456789012:role/BreakGlassRole"
          ]
        },
        "NotIpAddressIfExists": {
          "aws:SourceIp": [
            "203.0.113.0/24"
          ]
        },
        "StringNotEqualsIfExists": {
          "aws:SourceVpc": "vpc-0abc123def456789"
        }
      }
    },
    {
      "Sid": "EnforceConsoleSourceVpcRegion",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "signin:Authenticate",
        "signin:AuthorizeOAuth2Access",
        "signin:CreateOAuth2Token"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:SourceVpc": "vpc-0abc123def456789"
        },
        "StringNotEqualsIfExists": {
          "aws:RequestedRegion": "us-west-2"
        }
      }
    }
  ]
}

That third statement matters when using aws:SourceVpc. VPC IDs are unique only within a Region. Replace us-west-2 with the Region valid for that VPC-based console access path. Do not confuse that value with the AWS Sign-In control-plane write Region.

Operational gate: This policy does nothing until console authorization is enabled. Do not let the JSON give you a false sense of enforcement. Enable it only after testing, and configure at least one excluded principal before production rollout to preserve emergency recovery access.

AWS Sign-In policy write operations must be routed through us-east-1; AWS replicates those policy changes globally. Read operations can target any commercial Region. That us-east-1 management requirement is separate from any aws:RequestedRegion condition you use to constrain VPC-based sign-in paths.

Test console sign-in controls in a non-production scope before broad rollout. A bad Sign-In RCP can lock out interactive console access. Recovery may require programmatic credentials, an OrganizationAccountAccessRole path, or AWS Support.


Syntax and Quota Traps That Matter in Production

RCPs look familiar enough to make people dangerous.

Customer-managed RCPs must use Deny. The only Allow behavior belongs to the AWS-managed RCPFullAWSAccess policy, which is automatically attached when RCPs are enabled and cannot be detached. That policy lets existing permissions pass through RCP evaluation; it does not grant access.

RCPs also require Principal to be "*". Each statement must contain either Resource or NotResource; most organization-wide invariants use "Resource": "*", while narrower exceptions should be modeled deliberately. You cannot put a specific IAM role ARN in the Principal element. Principal targeting must happen in the Condition block.

A customer-managed RCP also cannot use a global "*" as the sole Action. You must specify supported service prefixes such as s3:*, kms:*, sqs:*, or signin:*.

RCPs do not support NotPrincipal or NotAction.

That limitation matters. You cannot write a compact RCP that says "deny every action except this approved list" using NotAction. Use explicit deny statements against supported service actions. Policy scale and composition become real engineering constraints.

This is where elegant policy diagrams meet ugly character limits.

AWS Organizations currently lists SCPs at up to 10,240 characters and 10 attachments per root, OU, or account. RCPs remain capped at 5,120 characters and five attachments per root, OU, or account. RCPFullAWSAccess counts toward that five-policy quota, so teams practically have four custom RCP attachment slots at each node.

Use RCPs for high-level invariants. Do not use them for every local access rule. Push detailed application exceptions back to local resource policies, identity policies, permission boundaries, or session policies.


Deployment Exceptions You Cannot Ignore

RCPs are powerful, but they do not apply everywhere.

First, RCPs do not affect resources in the AWS Organizations management account. Keep ordinary workloads out of the management account.

Second, RCPs do affect resources in member accounts, including when the accessing principal is a member-account root user. This makes RCPs useful for reducing the blast radius of member-account root misuse.

Third, RCPs do not restrict service-linked roles. AWS services depend on service-linked roles to operate correctly, and AWS documents that RCPs do not apply to calls made by service-linked roles.

Fourth, RCPs do not apply to AWS-managed KMS keys. If your cryptographic perimeter requires organizational control over key use, design around customer-managed keys.

Fifth, RCPs do not affect kms:RetireGrant.

These exceptions are not fine print. They are the operating envelope.

The mistake is to treat RCPs as a universal override switch. They are not. They are an organization-level resource-boundary control with a documented scope.


Validate Before You Attach to the Root

An RCP attached at the organization root can break production quickly.

That is not a reason to avoid RCPs. It is a reason to roll them out like production infrastructure.

Before any candidate RCP is attached broadly, run it through a policy-as-code gate that checks at least the following:

  • Effect is Deny for every customer-managed RCP statement.
  • Principal is exactly "*".
  • The statement uses Resource or NotResource intentionally; broad invariants are scoped explicitly, and exceptions are not hidden accidentally.
  • The policy does not use NotAction or NotPrincipal.
  • The policy does not use a global "*" as the sole Action.
  • Every action prefix belongs to a service currently supported by RCPs.
  • The minified policy is under 5,120 characters.
  • Break-glass and service-integration exceptions are intentional and documented.

IAM Access Analyzer should be part of that workflow. External access analysis detects public and cross-account access findings for AWS resources and is available at no additional charge. Internal access analysis pinpoints which IAM roles and users within the organization can access business-critical resources, but it is a paid feature billed per monitored resource, per Region, per month. Unused access analysis and custom policy checks have separate pricing models.

Use Access Analyzer deliberately. Do not enable internal access analysis blindly across all resources in all Regions. Start with the resources that matter most: sensitive S3 buckets, customer-managed KMS keys, DynamoDB tables, secrets, queues, repositories, logs, and console sign-in policy paths.

A practical rollout sequence:

  1. Create a sandbox OU with representative workloads.
  2. Attach the candidate RCP to the sandbox OU only.
  3. Run Access Analyzer against the critical resources affected by the policy.
  4. Review CloudTrail for expected and unexpected AccessDenied events.
  5. Add exceptions only when they represent intentional architecture, not convenience.
  6. Move the policy upward one OU at a time.
  7. Attach to the organization root only after the invariant has survived lower-scope testing.

RCPs are preventive controls. Treat deployment like a production change. Do not treat it like a documentation cleanup.


The Real Architectural Shift

The old model demanded every resource policy be perfect.

The new model requires the organization to define what must always be true.

That is the architectural shift.

SCPs continue to be essential. They govern which identities in your organization can do what. They are still the right controls for restricting Regions, blocking dangerous administrative actions, and setting maximum permissions for principals in member accounts.

RCPs add the missing resource-side boundary. They govern what access your resources can accept. They are the right controls for avoiding unintended external access, protecting customer-managed keys, constraining service-principal access, enforcing transport invariants where supported, and restricting AWS Management Console sign-in by network.

A mature AWS data perimeter uses both: SCPs constrain identity behavior, RCPs constrain resource exposure, resource policies define legitimate local sharing, IAM Access Analyzer validates what the policy graph actually allows, and CloudTrail tells you what broke during rollout.

That is the governance model cloud security teams were missing: not a pile of perfect bucket policies, but a layered authorization system where local misconfiguration is no longer the final line of defense.

RCPs do not eliminate the need for careful IAM design. They do not replace resource policies. They do not cover every AWS service. They do not secure management-account resources. They are not a substitute for testing.

But used correctly, they close one of the most important structural gaps in AWS Organizations: the gap between governing your identities and controlling your resources.

For senior cloud architects, that is the point.

A real data perimeter is not only about who can act. It is about what your resources are never allowed to accept.


References