AWS Governance

Enforcing Org-Wide Guardrails with AWS Organizations, SCPs, and Delegated Administration

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 NotAction list is load-bearing. Global services have their endpoints in us-east-1; if you omit iam or sts and us-east-1 is not in your allowed set, you can sever the org’s ability to assume roles. Keep us-east-1 allowed 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-account calls — you must repeat the call in every region you operate. IAM Access Analyzer and most other services go through the generic Organizations register-delegated-administrator and 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

AWSOrganizationsSCPRCPGovernanceSecurity

Comments

Keep Reading