Identity policies tell you what a principal can do. Guardrails tell you what nobody in the org can do, no matter what their IAM policy says. This guide builds that second layer end to end with Service Control Policies (SCPs), Resource Control Policies (RCPs), and declarative policies — the preventive controls that ride above every account — and shows how to delegate your security tooling out of the management account without engineering a self-lockout.
How SCPs evaluate: deny-by-intersection, never grant
The single most important fact about an SCP, and the one people get wrong: an SCP never grants a permission. It is a filter on the maximum permissions available to principals in an account. The effective permission set for an action is the intersection of every applicable policy:
Effective allow = identity policy (or resource policy)
INTERSECT every SCP from root down to the account
INTERSECT every RCP from root down to the account
If an action is not Allowed by an identity policy, an SCP that “allows” it changes nothing — the request is still denied. Flip it around and the SCP becomes powerful: if any SCP in the chain denies the action, no identity policy anywhere can win it back. A request succeeds only when it is allowed by the principal’s own policy and is not blocked by any SCP and is not blocked by any RCP on the target resource.
| Policy type | Acts on | Can grant? | Affects management account? |
|---|---|---|---|
| SCP | Principals (the identity side) | No, deny only | No |
| RCP | Resources (the resource side) | No, deny only | No |
| Declarative policy | Service configuration (e.g. EC2) | n/a, sets state | Yes, it configures the service |
| Identity / resource policy | Grants | Yes | Yes |
Three consequences flow from this and shape everything below:
The management account is exempt from SCPs and RCPs. Policies attached at the root do not restrict the management account. That is a feature (you cannot brick your own org from the top) and a trap (never run workloads there — they are unguardrailed). Treat the management account as a billing-and-org control plane only.
There are two SCP strategies. The default FullAWSAccess policy allows everything, and you attach deny policies on top — this is the maintainable model. The alternative is an allow-list model where you remove FullAWSAccess and explicitly allow services; it is far more brittle because every new service launch is blocked by default. Use the deny model. Reserve allow-list SCPs for a tightly-scoped sandbox or a regulated workload OU.
Step 1 — Designing a layered policy strategy
Guardrails attach at three scopes, and the discipline is putting each rule at the broadest scope where it is universally true.
Root
├── SCP: org-wide non-negotiables (region lock, root lockdown, protect security infra)
│
├── OU: Security -> tightest; protect log archive + audit roles
├── OU: Infrastructure -> network/shared-services guardrails
├── OU: Workloads
│ ├── OU: Prod -> deny destructive/escape actions
│ └── OU: NonProd -> looser, allow experimentation
└── OU: Sandbox -> region + spend guardrails, otherwise open
The rule of thumb: a control that must hold for every account (deny disabling CloudTrail, deny leaving the org, region restriction) belongs at the root. A control specific to a workload tier belongs on the OU. Account-level SCP attachments exist but are an anti-pattern at scale — they do not survive account moves and become impossible to audit. Keep your guardrails attached to OUs and let account placement do the work.
Before any of this works, enable the policy types on the root (they are off by default):
# Discover the root ID, then enable each policy type
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
aws organizations enable-policy-type --root-id "$ROOT_ID" --policy-type SERVICE_CONTROL_POLICY
aws organizations enable-policy-type --root-id "$ROOT_ID" --policy-type RESOURCE_CONTROL_POLICY
aws organizations enable-policy-type --root-id "$ROOT_ID" --policy-type DECLARATIVE_POLICY_EC2
Step 2 — Foundational deny SCPs
These are the four guardrails almost every org should ship first. Each is a standalone Deny statement; bundle related ones into a single policy to stay under the 5 KB per-SCP size limit (a real constraint — whitespace counts, so minify in CI).
Region restriction. Confine the org to approved regions, but exempt global and global-endpoint services or you will break IAM, Organizations, CloudFront, Route 53, and support. Use aws:RequestedRegion with a NotAction carve-out:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyOutsideApprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*", "organizations:*", "sts:*",
"cloudfront:*", "route53:*", "waf:*", "wafv2:*",
"support:*", "trustedadvisor:*",
"globalaccelerator:*", "budgets:*", "ce:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["us-east-1", "eu-west-1"]
}
}
}
]
}
The
NotActionlist is load-bearing. Global services have their endpoints inus-east-1; if you omitiamorstsandus-east-1is not in your allowed set, you can sever the org’s ability to assume roles. Keepus-east-1allowed even in a single-region EU org, or keep the carve-out exhaustive. Test this one harder than any other policy.
Root-user lockdown. Member-account root credentials should never be used for day-to-day actions. Deny everything performed by the account root:
{
"Sid": "DenyRootUserActions",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringLike": { "aws:PrincipalArn": "arn:aws:iam::*:root" }
}
}
Protect CloudTrail and the audit trail. Stop any principal from blinding your detective controls — disabling, deleting, or stopping organization trails and Config:
{
"Sid": "ProtectAuditPipeline",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail",
"config:DeleteConfigurationRecorder",
"config:StopConfigurationRecorder",
"config:DeleteDeliveryChannel"
],
"Resource": "*"
}
Protect security roles and break-glass. A deployment or admin role provisioned by the platform team must not be deletable or modifiable by workload principals. Deny IAM write actions against the protected roles except when the caller is the role itself or a designated org-admin path, using ArnNotLike on aws:PrincipalArn:
{
"Sid": "ProtectSecurityRoles",
"Effect": "Deny",
"Action": [
"iam:AttachRolePolicy", "iam:DetachRolePolicy",
"iam:DeleteRole", "iam:DeleteRolePolicy", "iam:PutRolePolicy",
"iam:UpdateRole", "iam:UpdateAssumeRolePolicy",
"iam:PutRolePermissionsBoundary", "iam:DeleteRolePermissionsBoundary"
],
"Resource": [
"arn:aws:iam::*:role/SecurityAudit",
"arn:aws:iam::*:role/aws-controltower-*",
"arn:aws:iam::*:role/OrgBreakGlass"
],
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/OrgPlatformAdmin"
}
}
}
That exception clause is the difference between a guardrail and a foot-gun. Without it you can lock the role so hard that nobody, including the role’s intended manager, can ever touch it again — and SCPs do not apply to the management account, so your only escape would be moving the account out of the org temporarily. Always leave one authenticated path in.
Step 3 — Conditional SCPs with org, tags, and service exceptions
Blanket denies are blunt. The expert move is conditioning a deny so it fires only outside a trusted boundary.
Lock data movement to your org with aws:PrincipalOrgID. This SCP denies sharing actions unless the calling principal belongs to your organization, which neutralizes a whole class of confused-deputy and exfiltration paths:
{
"Sid": "DenyShareOutsideOrg",
"Effect": "Deny",
"Action": [
"ec2:ModifyImageAttribute",
"ec2:ModifySnapshotAttribute",
"rds:ModifyDBSnapshotAttribute"
],
"Resource": "*",
"Condition": {
"StringNotEquals": { "aws:PrincipalOrgID": "o-abcd1234ef" }
}
}
Enforce tag-on-create. Require a cost or owner tag at creation time using aws:RequestTag and aws:TagKeys. The pattern is “deny the create action when the required tag key is absent”:
{
"Sid": "RequireProjectTagOnInstances",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"Null": { "aws:RequestTag/Project": "true" }
}
}
Exempt AWS services from a deny. The subtle one. When you write a strict deny, AWS services acting on a principal’s behalf can get caught in it. Two distinct condition keys cover two distinct cases, and conflating them is the most common SCP bug I see in reviews:
| Key | True when | Use to exempt |
|---|---|---|
aws:PrincipalIsAWSService |
A service principal acts directly on your resource (e.g. cloudtrail.amazonaws.com writing to S3) |
Service principals making direct calls |
aws:ViaAWSService |
A service makes the call using your principal’s credentials (forward access sessions) | A user action that fans out through a service |
So a deny that should not apply when, say, Athena or a service-linked role re-drives a request on the user’s behalf gets a BoolIfExists guard:
{
"Sid": "DenyKmsDeleteExceptViaService",
"Effect": "Deny",
"Action": ["kms:ScheduleKeyDeletion", "kms:DisableKey"],
"Resource": "*",
"Condition": {
"BoolIfExists": { "aws:ViaAWSService": "false" }
}
}
Use BoolIfExists (not Bool) for these keys: if the key is absent from the request context, a plain Bool comparison evaluates unpredictably, whereas ...IfExists treats absence as “the principal called directly.”
Step 4 — Resource Control Policies for the data perimeter
SCPs filter what your principals can do. They say nothing about an external principal hitting your S3 bucket via a bucket policy. RCPs close that gap: they are deny-only policies that attach to root/OU/account like SCPs but evaluate on the resource side, capping the effective permissions of every resource policy in scope. This is how you build an org-wide data perimeter — “only identities from my org, or expected AWS services, may touch my data” — without editing thousands of individual bucket and key policies.
RCPs cover a specific, growing service list. At launch: Amazon S3, AWS STS, AWS KMS, Amazon SQS, and AWS Secrets Manager, with Amazon ECR and OpenSearch Serverless added since. RCPs do not apply to services outside that list, so do not assume one RCP blankets every resource type — confirm the service is in scope before relying on it.
A canonical perimeter RCP: deny access to S3, SQS, KMS, and Secrets Manager resources unless the caller is in your org or is an AWS service. Note the explicit allowance for service principals so CloudTrail, log delivery, and similar do not break:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnforceOrgIdentityPerimeter",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:*", "sqs:*", "kms:*", "secretsmanager:*"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"aws:PrincipalOrgID": "o-abcd1234ef"
},
"BoolIfExists": {
"aws:PrincipalIsAWSService": "false"
}
}
}
]
}
Two things make RCPs safe to roll out. First, like SCPs they never grant — adding an RCP can only ever reduce access, so it cannot accidentally open a resource. Second, the management account is again exempt. The risk is the inverse of SCPs: an over-broad RCP can lock legitimate cross-account or service access you forgot about. Stage it (Step 6) and watch CloudTrail for AccessDenied before you widen scope.
Step 5 — Declarative policies and delegated administration
Declarative policies are a different animal from SCPs and RCPs. Rather than filtering API calls, they pin the configuration state of a service across the org, and AWS guarantees the baseline holds even as the service ships new APIs and as new accounts join. The first supported domain is EC2, where you can enforce VPC Block Public Access, IMDSv2, and block-public-access for EBS snapshots and AMIs — org-wide, in one object:
{
"ec2_attributes": {
"vpc_block_public_access": {
"internet_gateway_block": {
"mode": { "@@assign": "block-bidirectional" },
"exclusions_allowed": { "@@assign": "disabled" }
}
},
"instance_metadata_defaults": {
"http_tokens": { "@@assign": "required" }
},
"snapshot_block_public_access": {
"state": { "@@assign": "block-all-sharing" }
}
}
}
# Create and attach the declarative policy to an OU
aws organizations create-policy \
--name ec2-baseline \
--type DECLARATIVE_POLICY_EC2 \
--content file://ec2-declarative.json
aws organizations attach-policy \
--policy-id p-examplepolicyid \
--target-id ou-root-workloads
The @@assign operator is declarative-policy syntax, not IAM JSON — that is how the policy sets a value rather than allowing or denying an action.
Delegated administration gets your security services out of the management account, which you should not be logging into daily. You enable trusted access once, then nominate a member account (typically a dedicated Security or Audit account) as the delegated administrator. The exact API differs by service, and this trips people up:
# IAM Access Analyzer and most services: the Organizations API
aws organizations enable-aws-service-access \
--service-principal access-analyzer.amazonaws.com
aws organizations register-delegated-administrator \
--account-id 222233334444 \
--service-principal access-analyzer.amazonaws.com
# GuardDuty: its OWN API, and it is regional
aws guardduty enable-organization-admin-account \
--admin-account-id 222233334444 --region us-east-1
# Security Hub: also its own API, also regional
aws securityhub enable-organization-admin-account \
--admin-account-id 222233334444 --region us-east-1
GuardDuty and Security Hub are regional services delegated through their own
enable-organization-admin-accountcalls — you must repeat the call in every region you operate. IAM Access Analyzer and most other services go through the generic Organizationsregister-delegated-administratorand only need it once. Mixing these up leaves half your regions unmanaged and is a silent gap auditors love to find.
Step 6 — Safe rollout without self-lockout
The failure mode that keeps platform teams up at night is attaching a deny at the root that locks out the very automation that manages the org. Sequence the rollout to make that impossible.
1. Stage in a Sandbox OU first. Create an OU containing only throwaway accounts, attach the new SCP/RCP there, and exercise the real workflows your teams run. Nothing learned in a Terraform plan substitutes for watching an actual terraform apply succeed or fail under the policy.
2. Simulate before you attach. Validate the policy document, then dry-run it against real principals and actions with the IAM policy simulator, which understands SCP context:
# Lint the document for syntax/grammar before it ever attaches
aws accessanalyzer validate-policy \
--policy-document file://scp-region-lock.json \
--policy-type SERVICE_CONTROL_POLICY
# Simulate: would this principal's action survive the guardrails?
aws iam simulate-custom-policy \
--policy-input-list file://scp-region-lock.json \
--action-names "ec2:RunInstances" \
--context-entries \
"ContextKeyName=aws:RequestedRegion,ContextKeyValues=ap-south-1,ContextKeyType=string"
3. Roll out OU by OU, least-critical first. Promote the same policy outward: Sandbox, then NonProd, then Infrastructure, then Prod, then Security last. The Security OU holds your log archive and audit roles, so it has the least margin for a mistake — it goes when you are most confident, not first.
4. Never test guardrails from the management account. Because the management account is exempt, a policy can look perfectly fine from there while it has bricked every member account. Always validate from a principal inside a target OU.
The structural insurance against lockout: keep one break-glass role that your SCPs explicitly exempt (the ArnNotLike clause in Step 2), store its credentials offline, and confirm it can still operate after every guardrail change. If a deploy goes wrong, you assume break-glass and fix forward — you never start deleting policies blind.
Enterprise scenario
A fintech platform team rolled out the data-perimeter RCP from Step 4 across the org and within an hour their nightly RDS snapshot copy to a dedicated DR account started failing with AccessDenied on the destination KMS key. The team’s first instinct was that the RCP was too broad — it was not. The copy was a cross-account snapshot share encrypted with a KMS key in the DR account, and the principal performing the final decrypt was the RDS service-linked role, whose calls carry aws:PrincipalOrgID of neither org during part of the workflow. The BoolIfExists guard on aws:PrincipalIsAWSService was meant to let that through, but the KMS grant created by RDS evaluated with the key context absent, so the deny fired.
The fix was not to weaken the perimeter. They scoped the KMS portion of the RCP to also honor the grant-issuing service via kms:ViaService, keeping S3, SQS, and Secrets Manager fully locked:
{
"Sid": "EnforceOrgPerimeterExceptKmsGrants",
"Effect": "Deny",
"Principal": "*",
"Action": ["kms:Decrypt", "kms:CreateGrant", "kms:GenerateDataKey"],
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": { "aws:PrincipalOrgID": "o-abcd1234ef" },
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" },
"Null": { "kms:GranteePrincipal": "true" }
}
}
The lesson: a perimeter RCP that looks airtight in review can break a legitimate service-mediated flow you never see in normal traffic. They caught it only because the RCP was staged in a Sandbox OU mirroring the DR topology — the production rollout never broke.
Verify
Confirm the guardrails are attached, enforced, and observable:
# Policy types are enabled on the root
aws organizations list-roots \
--query 'Roots[0].PolicyTypes[].{type:Type,status:Status}' --output table
# What policies actually apply to a given account (the real audit question)
aws organizations list-policies-for-target \
--target-id 111122223333 --filter SERVICE_CONTROL_POLICY --output table
aws organizations list-policies-for-target \
--target-id 111122223333 --filter RESOURCE_CONTROL_POLICY --output table
# Delegated admins are registered for the security services
aws organizations list-delegated-administrators --output table
aws guardduty list-organization-admin-accounts --region us-east-1
# Prove enforcement: this should fail with AccessDenied from inside a guarded account
aws ec2 run-instances --image-id ami-0123456789abcdef0 \
--instance-type t3.micro --region ap-south-1 # blocked by region lock
The decisive test is the last one: a denied call from a real member-account principal, visible as an AccessDenied event in CloudTrail. A guardrail you have not watched block something is a guardrail you do not yet trust.
Rollout checklist
Pitfalls
- Assuming an SCP grants access. It only filters; the principal still needs an
Allowfrom an identity policy. An SCP that “allows” a service does nothing on its own. - Running workloads in the management account. It is exempt from SCPs and RCPs — anything there is unguardrailed. Keep it for org and billing only.
- Region locks that omit global services. Forgetting
iam/stsin theNotActionof a region-restriction SCP can sever role assumption org-wide. Test this policy hardest. - Protecting a role with no exception. A deny on IAM write actions with no
ArnNotLikecarve-out can make a role permanently unmanageable. Always leave one authenticated path. - Confusing
BoolwithBoolIfExists. Foraws:ViaAWSServiceandaws:PrincipalIsAWSService, absence of the key with plainBoolevaluates unpredictably — use theIfExistsform. - Over-broad RCPs. A perimeter RCP can silently block legitimate cross-account or service access. Stage it and watch for
AccessDeniedbefore widening. - Forgetting regional delegation. GuardDuty and Security Hub delegate per region through their own APIs — register in every operating region or leave blind spots.
- Testing from the management account. It will look healthy while member accounts are broken. Always verify from inside a target OU.