AWS Lesson 86 of 123

AWS Landing Zone: Guardrails (SCPs & Controls) — Preventive SCPs, Detective Config Rules, Proactive Hooks & the Mandatory/Recommended/Elective Catalog

In a nutshell

Imagine a mountain highway with a sheer drop on one side. Nobody builds a fence across the road — that would stop all traffic. Instead they bolt a steel guardrail along the edge. You still choose your speed, your lane, and your destination; the guardrail only stops the one outcome nobody wants: going over the cliff. A landing-zone guardrail is exactly that idea applied to an AWS account: a rule that lets a team build freely inside the lane while making the catastrophic outcomes — deleting the audit trail, leaving the organization, opening a database to the whole internet — mechanically hard or impossible.

There are three flavours of safety on that highway, and AWS gives you one control type for each:

Control Tower calls all three controls (the word that replaced “guardrails” in the console — everyone still says both), bundles hundreds of ready-made ones into a library, and lets you switch each on at whatever altitude of the account tree you like. This lesson teaches you to read that library, wire each of the three behaviours by hand, and choose the right one for a given requirement. The guiding philosophy is “guardrails, not gates”: maximise team autonomy and speed, and spend your enforcement budget only on the handful of things that must never happen. A landing zone drowning in gates becomes an exemption-ticket factory; a landing zone with the right guardrails scales to 800 accounts without a human in the loop.

Level: Advanced · Time: ~60 min

Prerequisites — you should already understand the organization tree and OU design from the earlier parts of this series:

After this lesson you will be able to:

Where this fits

In AWS Landing Zone & Control Tower, parts 1–3 established the org structure and operating spine: the AWS Organizations account tree, the OU design (Security, Infrastructure, Workloads, Sandbox, Suspended), the three foundational accounts (management, Log Archive, Audit), and centralized CloudTrail/Config logging. None of that enforces anything on its own. Part 4 — Guardrails (SCPs & Controls) is where the landing zone grows teeth: it is the layer that takes governance intent and turns it into rules that block, detect, or prevent non-compliant behaviour automatically across every account, including ones that don’t exist yet. AWS Control Tower packages these as controls (the term that replaced “guardrails” in the console, though everyone still says guardrails), and a control is delivered through one of three behaviours — preventive via Service Control Policies, detective via AWS Config rules, proactive via CloudFormation Hooks — and assigned through one of three categories — mandatory, strongly recommended, elective. This article goes deep on all four of those sub-components, because getting the behaviour-to-mechanism mapping right is what separates a landing zone that scales to 800 accounts from one that becomes an exemption-ticket factory.

AWS Landing Zone & Control Tower — animated overview

Preventive guardrails: Service Control Policies (SCPs)

What it is

A Service Control Policy is an AWS Organizations policy that sets the maximum available permissions for the IAM principals (users and roles) in the accounts it is attached to. It is a guardrail, not a grant: an SCP never gives anyone permission — it only defines the ceiling that an account’s own IAM policies operate beneath. The effective permission of any principal is the intersection of what the SCP allows and what the principal’s IAM (identity and resource) policies allow. If either side says no, the answer is no. Crucially, SCPs apply to everyone in the member account including the account root user — but they explicitly do not apply to the management account, to service-linked roles, or to the org’s service-managed resources. That management-account blind spot is the single most important SCP fact to internalise: never run workloads there, because you cannot fence them in.

SCPs attach to three target types in the org tree — the root, an Organizational Unit, or an individual account — and they inherit downward. A policy on the root applies to every account; a policy on the Workloads OU applies to every account in and below it. Inheritance is additive in restriction: a child OU can make things stricter but can never loosen what a parent SCP forbade. There are two authoring styles:

A closely related newer control is the Resource Control Policy (RCP). Where an SCP bounds what principals in your accounts can do, an RCP bounds what any principal (including external/cross-account ones) can do to resources in your accounts — the resource-perimeter complement to the identity perimeter. SCPs and RCPs are both Organizations policies, both inherit down the tree, and both intersect with the relevant grant; a mature landing zone uses both.

Why it matters

SCPs are the only AWS mechanism that can make a permission impossible to grant — not merely ungranted, but unobtainable even by an account’s own administrator or root user. That property is what lets a platform team hand a workload account to a product team with full autonomy inside the box while guaranteeing certain things can never happen: the central CloudTrail can’t be stopped, the org can’t be left, the encryption key for the log bucket can’t be deleted, resources can’t be created outside approved regions. Without SCPs you are relying on every account’s IAM being authored correctly forever, which is a bet you will lose at the 50th account. With SCPs, a non-negotiable written once at the root is enforced on account 800 exactly as on account 1, and cannot be weakened by a child scope.

They are also the cleanest way to encode compliance and data-residency law as code: a single region-deny SCP on the EU OU does more for GDPR data-residency assurance than a hundred pages of policy documentation, because it is mechanically unbreakable.

How to do it well

The canonical SCP patterns

Guardrail intent Mechanism Where to attach
Prevent member accounts leaving the org Deny organizations:LeaveOrganization Root
Region allowlist (data residency) Deny NotAction:[global svcs] when aws:RequestedRegion ∉ allowlist Root or geo OU
Protect CloudTrail / Config / log bucket Deny cloudtrail:Stop*/Delete*, config:Delete*, S3 deletes on log bucket Root
Block root-user activity except break-glass Deny * with aws:PrincipalArn = root and aws:PrincipalType = Root Root
Require IMDSv2 / deny IMDSv1 launches Deny ec2:RunInstances unless ec2:MetadataHttpTokens = required Workloads OU
Sandbox service allowlist Deny NotAction:[approved services] Sandbox OU
Deny disabling security services Deny guardduty:Delete*, securityhub:Disable*, macie2:Disable* Security + Workloads OUs
Resource perimeter (block external access) RCP with aws:PrincipalOrgID StringNotEquals Root

Writing a custom SCP: five worked examples

The table above names the canonical patterns; this is where we actually write them. Read each policy top-to-bottom — an SCP is just an IAM-syntax JSON document with Version and a Statement list, but the meaning of each field flips because an SCP is a filter, not a grant. Every account IDs below is a placeholder (123456789012, o-exampleorgid) — substitute your own. All five are deny-list statements meant to sit alongside the root’s FullAWSAccess, not replace it.

1 — Prevent an account from leaving the organization. The simplest and most universal guardrail. If a member account can call LeaveOrganization, it can escape every SCP you ever wrote, so this one goes on the root.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyLeaveOrganization",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    }
  ]
}

There is no Condition and no Principal — SCPs never take a Principal because they apply to all principals in the target accounts, including the account root user. Effect: Deny on a single action, and the door is sealed for every current and future account under the root.

2 — Lock resources to approved regions (data residency), without locking yourself out of IAM. This is the policy that most often bricks an account, because AWS’s global services (IAM, Organizations, Route 53, CloudFront, STS, Support, Shield, WAF for CloudFront, billing) authenticate through endpoints in us-east-1. A naive “deny everything outside eu-west-1” also denies IAM, and now nobody can administer the account. The fix is NotAction — “deny everything except these global actions when the request is outside the allowed regions” — plus an ArnNotLike carve-out for the automation roles that must cross the line.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideApprovedRegions",
      "Effect": "Deny",
      "NotAction": [
        "iam:*",
        "organizations:*",
        "sts:*",
        "route53:*",
        "cloudfront:*",
        "waf:*",
        "wafv2:*",
        "shield:*",
        "support:*",
        "globalaccelerator:*",
        "budgets:*",
        "ce:*",
        "cur:*",
        "health:*",
        "trustedadvisor:*",
        "artifact:*",
        "account:*",
        "ec2:DescribeRegions"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": [
            "eu-west-1",
            "eu-central-1"
          ]
        },
        "ArnNotLike": {
          "aws:PrincipalArn": [
            "arn:aws:iam::*:role/AWSControlTowerExecution",
            "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*"
          ]
        }
      }
    }
  ]
}

Three things make this correct. NotAction inverts the match, so the deny lands on every action except the listed global ones — that keeps IAM and Organizations working from anywhere. aws:RequestedRegion is the global condition key carrying the target region of the call; StringNotEquals fires the deny only when the region is not in your allowlist. The ArnNotLike on aws:PrincipalArn exempts the Control Tower execution role and IAM Identity Center’s roles so automation and SSO still function during and after rollout. Note two caveats: this list is illustrative — treat AWS’s current “deny by requested Region” reference as authoritative and add services like directconnect, networkmanager, or route53domains if you use them; and do not add regional services such as kms or config to NotAction, or you defeat the residency guarantee. If you enable Control Tower’s built-in Region deny control, it writes and maintains an equivalent SCP for you.

3 — Restrict the account root user. The root user bypasses IAM policies but not SCPs, which makes an SCP the only way to fence it in. Deny everything when the caller is the root principal; handle emergencies through a separate, documented break-glass path.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAllRootUserActions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:PrincipalArn": "arn:aws:iam::*:root"
        }
      }
    }
  ]
}

aws:PrincipalArn matches the calling identity’s ARN; the *:root pattern matches the root user of any member account in the target. Because this denies everything, plan break-glass deliberately: temporarily detach the SCP under a change ticket, or narrow the deny with NotAction for a tiny recovery set. Better still, adopt AWS’s centralized root access management (2024) to remove long-lived root credentials from member accounts entirely, so there is far less to fence. Control Tower ships mandatory controls that already harden root; this custom SCP is for organizations that want a hard, self-owned deny on top.

4 — Require IMDSv2 on every EC2 instance. IMDSv1’s request/response metadata endpoint is a well-known SSRF credential-theft vector; IMDSv2’s session tokens close it. You cannot express “the instance must use IMDSv2” as a single API verb, but you can condition ec2:RunInstances on the metadata options in the request.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRunInstancesWithoutIMDSv2",
      "Effect": "Deny",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:*:*:instance/*",
      "Condition": {
        "StringNotEquals": {
          "ec2:MetadataHttpTokens": "required"
        }
      }
    },
    {
      "Sid": "DenyMetadataDowngrade",
      "Effect": "Deny",
      "Action": "ec2:ModifyInstanceMetadataOptions",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "ec2:MetadataHttpTokens": "required"
        }
      }
    }
  ]
}

The first statement scopes the deny to the instance/* resource of a RunInstances call and fires unless ec2:MetadataHttpTokens equals required — that key is only required when the launcher explicitly asks for IMDSv2. The second statement stops someone re-enabling IMDSv1 later with ModifyInstanceMetadataOptions. This is a great example of an SCP reasoning about a request condition key rather than a raw verb — and of its limit: it governs the launch request, not the running instance’s later behaviour, which is why a detective Config rule (ec2-imdsv2-check) usefully backs it up.

5 — Deny making S3 data public. Public S3 buckets are the classic breach headline. Two statements cover the common paths: block anyone from turning off the account-level Block Public Access that your platform sets once, and deny public ACL grants outright.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDisablingAccountPublicAccessBlock",
      "Effect": "Deny",
      "Action": "s3:PutAccountPublicAccessBlock",
      "Resource": "*"
    },
    {
      "Sid": "DenyPublicBucketAndObjectAcls",
      "Effect": "Deny",
      "Action": [
        "s3:PutBucketAcl",
        "s3:PutObjectAcl"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-acl": [
            "public-read",
            "public-read-write",
            "authenticated-read"
          ]
        }
      }
    }
  ]
}

The first statement freezes the account-level Block Public Access setting — set it ON in every account at vend time (Account Factory or a Config remediation), and this deny means no one can weaken it. The second denies any request whose canned-ACL header (s3:x-amz-acl) would grant public or cross-account read/write. Together they neutralise the two most common ways a bucket goes public. As always, an SCP only sees the API call; a resource created with a public bucket policy (not an ACL) is better caught by a detective s3-bucket-public-read-prohibited rule or a proactive hook — which is exactly why the next two sections exist.

Sizing reality: each of these is small, but you only get 5 SCPs per target and 5,120 characters per policy. In production you would merge statements 1, 3, and the security-service denies into one dense root SCP (multiple Sids in a single document), strip whitespace, and lean on NotAction/NotResource to stay under the character cap. Design consolidated from day one — discovering the ceiling after drafting ten single-purpose policies forces a painful rewrite.

Concrete artifacts, decisions, and tools

Callout: Control Tower writes and owns a set of SCPs to implement its preventive controls; they carry the aws-guardrails- name prefix and an aws-control-tower tag. Do not edit, reorder, or delete these manually — doing so puts the OU into a non-compliant/drift state that Control Tower will flag and may try to remediate. Layer your custom SCPs alongside them, not on top of them.

Detective guardrails: AWS Config rules

What it is

A detective control does not block anything — it continuously evaluates the live state of your resources against a desired configuration and reports compliant / non-compliant. In the landing zone this is delivered by AWS Config: Config records the configuration of supported resources as configuration items, stores their history, and runs Config rules against them. A rule is either AWS-managed (one of hundreds of prebuilt checks like s3-bucket-public-read-prohibited, encrypted-volumes, rds-storage-encrypted, iam-password-policy) or custom (backed by an AWS Lambda function or a Guard policy in Config Custom Policy rules). Rules evaluate on configuration change, on a periodic schedule, or both.

Across an organisation, detective controls are deployed at scale through AWS Config conformance packs — a pack is a named collection of Config rules and remediation actions deployed as a single CloudFormation-based unit, and an organization conformance pack rolls that bundle out to every member account from the management or a delegated administrator account in one operation. Control Tower’s detective controls are exactly this under the hood: managed Config rules deployed org-wide, with findings surfaced in the Control Tower console and (typically) aggregated into AWS Security Hub.

Why it matters

Preventive controls can only stop the things you thought to forbid in advance, and only at the API boundary. Detective controls cover the long tail: configuration drift introduced by a permitted-but-misused API, by an action a too-broad SCP exemption allowed, by a console change, or simply by a resource that was compliant when created and drifted later (a security group opened to 0.0.0.0/0 last Tuesday). They give you the continuous-compliance evidence auditors actually want — a timestamped, per-resource history of compliant/non-compliant state mapped to a framework — and they are the input to auto-remediation. A detective control is the difference between “we have a policy that says volumes must be encrypted” and “here is the live count of unencrypted volumes by account, trending down, with the remediation that runs on each new violation.”

They also catch what preventive controls structurally cannot: things created in the management account (no SCP), changes by service-linked roles, and slow drift. A landing zone with only preventive controls is blind to its own decay.

How to do it well

Detective control building blocks

Building block Role in the landing zone
Configuration recorder Captures configuration items per account; the data source for everything
AWS-managed Config rule Prebuilt compliance check (e.g. s3-bucket-server-side-encryption-enabled)
Custom Config rule (Lambda / Guard) Org-specific checks not covered by managed rules
Conformance pack A versioned bundle of rules + remediations deployed as one unit
Organization conformance pack The same bundle rolled out org-wide, current and future accounts
Config aggregator Single-pane org-wide compliance view (in Audit account)
SSM Automation remediation The corrective action a rule fires (auto or on approval)
Security Hub Normalises + scores findings against frameworks

A worked conformance pack with remediation

The building blocks above become concrete in a conformance pack template — a CloudFormation-style YAML that declares Config rules and their remediations as one versioned unit. Here is a minimal but real pack: two AWS-managed rules plus an automatic remediation that fires when a bucket is found public.

# cis-fsbp-baseline.yaml  — a trimmed organization conformance pack
Resources:
  S3PublicReadProhibited:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: s3-bucket-public-read-prohibited
      Source:
        Owner: AWS
        SourceIdentifier: S3_BUCKET_PUBLIC_READ_PROHIBITED
      Scope:
        ComplianceResourceTypes:
          - AWS::S3::Bucket

  EncryptedVolumes:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: encrypted-volumes
      Source:
        Owner: AWS
        SourceIdentifier: ENCRYPTED_VOLUMES
      Scope:
        ComplianceResourceTypes:
          - AWS::EC2::Volume

  S3PublicReadRemediation:
    Type: AWS::Config::RemediationConfiguration
    Properties:
      ConfigRuleName: s3-bucket-public-read-prohibited
      TargetType: SSM_DOCUMENT
      TargetId: AWS-DisableS3BucketPublicReadWrite
      Automatic: true
      MaximumAutomaticAttempts: 3
      RetryAttemptSeconds: 60
      Parameters:
        AutomationAssumeRole:
          StaticValue:
            Values:
              - arn:aws:iam::123456789012:role/ConfigRemediationRole
        S3BucketName:
          ResourceValue:
            Value: RESOURCE_ID

Walk the three resources. The two AWS::Config::ConfigRule blocks each reference a prebuilt check by its SourceIdentifierS3_BUCKET_PUBLIC_READ_PROHIBITED and ENCRYPTED_VOLUMES are AWS-managed, so you write no Lambda. Scope.ComplianceResourceTypes narrows evaluation to the resource type that matters, which keeps evaluations (and the bill) down. The AWS::Config::RemediationConfiguration is the interesting part: TargetId points at AWS-DisableS3BucketPublicReadWrite, a real AWS-owned SSM Automation document; Automatic: true makes it self-heal without human approval; AutomationAssumeRole is the role SSM assumes to do the fix; and the magic token RESOURCE_ID tells Config to pass the non-compliant bucket’s id into the runbook. Flip Automatic to false for anything that could disrupt a running workload (a security-group change), so a human approves the fix.

Deploy it org-wide from the delegated Config admin (usually the Audit account), not click-by-click:

# One-time: let the Audit account manage Config org-wide
aws organizations register-delegated-administrator \
  --service-principal config-multiaccountsetup.amazonaws.com \
  --account-id 123456789012

# Roll the pack out to every current AND future account
aws configservice put-organization-conformance-pack \
  --organization-conformance-pack-name cis-fsbp-baseline \
  --template-s3-uri s3://my-conformance-templates/cis-fsbp-baseline.yaml \
  --delivery-s3-bucket awsconfigconforms-my-org \
  --excluded-accounts 111111111111

put-organization-conformance-pack lands the identical rule set on all member accounts; --excluded-accounts lets you skip, say, the management account; and because it is an organization pack, an account created next year inherits it automatically. Version the template in Git and deploy it through your pipeline.

Security Hub standards as detective guardrails at scale

Config gives you rules; Security Hub gives you curated bundles of rules mapped to a compliance framework, scored, deduplicated, and aggregated. Instead of hand-picking 200 Config rules, you switch on a standard and inherit AWS’s mapping. The four you will meet most:

Standard What it covers Typical use
AWS Foundational Security Best Practices (FSBP) AWS’s own broad security baseline across services Enable everywhere as the default baseline
CIS AWS Foundations Benchmark (v1.2 / v1.4 / v3.0) Community-consensus hardening checks Common audit ask; enable estate-wide
PCI DSS Payment-card controls The PCI OU / cardholder-data accounts
NIST SP 800-53 Rev 5 US federal control catalogue Regulated / government workloads

Enable Security Hub org-wide from a delegated admin and turn on the baseline standards:

# Delegate Security Hub administration to the Audit account
aws securityhub enable-organization-admin-account \
  --admin-account-id 123456789012

# In the delegated admin: auto-enable Security Hub in new accounts
aws securityhub update-organization-configuration \
  --auto-enable \
  --auto-enable-standards DEFAULT

# Subscribe an account to the FSBP standard explicitly
aws securityhub batch-enable-standards \
  --standards-subscription-requests \
  '[{"StandardsArn":"arn:aws:securityhub:us-east-1::standards/aws-foundational-security-best-practices/v/1.0.0"}]'

Under the hood a Security Hub control such as S3.1 (“S3 general purpose buckets should have Block Public Access enabled”) or EC2.13 (“Security groups should not allow ingress from 0.0.0.0/0 to port 22”) is powered by one or more AWS Config rules — which is why Security Hub needs Config’s recorder switched on. Security Hub’s job is to normalise those results into the AWS Security Finding Format (ASFF), score each standard as a percentage, and let you route findings onward. From here you fan findings into EventBridge for ticketing and Slack alerts, and into Audit Manager for evidence collection — turning “we think we’re compliant” into “here is the live, per-account, per-control score, trending up.” When Control Tower enables its Security Hub-based detective controls, these are the very same standards, surfaced in the Control Tower console with SH.-prefixed control IDs (covered next).

Concrete artifacts, decisions, and tools

Proactive controls: CloudFormation Hooks

What it is

A proactive control is the third behaviour, and it closes the gap between preventive and detective. It checks resource configuration at provisioning time, before the resource is created or updated, and blocks the deployment if it would be non-compliant — but unlike an SCP, it inspects the desired resource properties, not just the API verb. The delivery mechanism is AWS CloudFormation Hooks: a hook runs during a CloudFormation stack operation, after the template is submitted but before resources are provisioned, and can return PASS or FAIL (with FAIL aborting the stack op, or WARN to advise without blocking). Control Tower’s proactive controls are managed CloudFormation Hooks AWS authors and operates on your behalf.

The conceptual difference is sharp:

Proactive controls therefore give you “encryption-required” semantics with no non-compliant window at all, for resources deployed via CloudFormation, without the bluntness of denying the whole API.

Why it matters

Proactive controls move enforcement left, into the pipeline, with zero remediation lag. For infrastructure-as-code shops — which any serious landing zone is — this is the highest-leverage control type for property-level requirements: encryption, versioning, public-access blocks, deletion protection, mandatory tags, instance-type allowlists, log-retention minimums. They catch the violation at cloudformation:CreateStack time, returning an error to the developer in their deploy with a clear reason, rather than blocking an opaque API call (SCP) or filing a finding hours later (Config). The result is a tighter feedback loop, fewer exemptions, and fewer resources that ever enter a non-compliant state in the first place.

The trade-off is scope: a CloudFormation Hook only fires for resources deployed through CloudFormation (which includes Service Catalog, CDK, and Control Tower’s own Account Factory). Resources created by the console, raw API/SDK calls, or Terraform are not seen by the hook — which is precisely why proactive, preventive, and detective controls are layered together rather than chosen between.

How to do it well

Behaviour comparison: the layering matrix

Property Preventive (SCP) Proactive (CFN Hook) Detective (Config rule)
When it acts At the API call At stack provisioning, pre-create After the resource exists
What it reasons about API action + condition keys Full proposed resource properties Recorded resource state
Effect Blocks the call Blocks the stack op Flags; can auto-remediate
Non-compliant window None None Yes (detect/remediate lag)
Coverage Every principal, every path CloudFormation-deployed only Every supported resource
Best for “Never allowed at all” “Property-level, in-pipeline” “Drift + audit evidence”
Underlying service AWS Organizations AWS CloudFormation Hooks AWS Config

A worked proactive hook with CloudFormation Guard

Managed proactive controls cover the common requirements, but org-specific rules need a custom hook, and the easiest way to write one is CloudFormation Guard (cfn-guard) — a compact DSL for asserting on resource properties. A Guard ruleset is plain text you keep in Git. This one requires every S3 bucket in a template to declare encryption and full Block Public Access:

# guard/s3-secure.guard
let s3_buckets = Resources.*[ Type == 'AWS::S3::Bucket' ]

rule S3_BUCKETS_ENCRYPTED when %s3_buckets !empty {
  %s3_buckets.Properties.BucketEncryption exists
    <<
      Violation: every S3 bucket must define BucketEncryption.
      Fix: add a ServerSideEncryptionConfiguration block.
    >>
}

rule S3_BLOCK_PUBLIC_ACCESS when %s3_buckets !empty {
  %s3_buckets.Properties.PublicAccessBlockConfiguration {
    BlockPublicAcls    == true
    BlockPublicPolicy  == true
    IgnorePublicAcls   == true
    RestrictPublicBuckets == true
  }
}

Read it as English: bind %s3_buckets to every bucket in the template; when that set is not empty, assert that each has a BucketEncryption block and all four Block-Public-Access flags set to true. The << ... >> text is the custom message the developer sees on failure — keep it actionable so they fix the property in seconds rather than filing a ticket. You register this ruleset as a CloudFormation Guard Hook and target it at your OUs; from then on it runs during every stack CREATE/UPDATE, before resources are provisioned, and returns PASS, FAIL, or WARN. Test it locally first — Guard runs offline against a rendered template, so you get instant feedback with no AWS call:

cfn-guard validate --rules guard/s3-secure.guard --data my-stack.template.json

A failing deploy then produces a message like this (representative):

Hook MyOrg::S3::SecureBucket failed for LoggingBucket:
  S3_BLOCK_PUBLIC_ACCESS — PublicAccessBlockConfiguration.BlockPublicPolicy
  expected [true] but got [false].
  Stack operation CREATE_FAILED.

Adopt every new proactive control the way you adopt a new SCP — in advisory mode first. Register the hook with failure mode WARN, leave it for a sprint or two, and read CloudTrail/hook logs for how many deploys it would have blocked and who owns them. Once the noise is understood and teams are warned, flip the hook to FAIL to enforce. This WARN → FAIL promotion is the proactive analogue of landing an SCP in a non-prod OU first, and it is what keeps proactive controls from becoming a surprise outage. Remember the coverage boundary from the matrix above: a hook only fires for CloudFormation, Service Catalog, and CDK deployments. Resources born from the console, a raw SDK call, or Terraform never touch it — so for a Terraform estate, run the same cfn-guard (or OPA) rules in CI as the moral equivalent, and keep the detective Config rule as the backstop.

Concrete artifacts, decisions, and tools

Mandatory, strongly-recommended and elective controls

What it is

This is the category axis of the Control Tower control library, orthogonal to behaviour. Every control AWS ships is classified by how strongly AWS recommends it and whether you can turn it off:

A separate axis, guidance, also marks some controls ELECTIVE/STRONGLY_RECOMMENDED/MANDATORY in the API alongside their behaviour, and AWS additionally groups controls into control objectives (e.g. “Establish logging and monitoring,” “Encrypt data at rest,” “Limit network access”) and frameworks so you can enable a coherent set rather than cherry-picking. The library is large (hundreds of controls) and spans the behaviours above — so a single elective control might be implemented as an SCP, a Config rule, or a hook.

Why it matters

The category model is how you right-size enforcement per OU without re-deriving it from first principles. Mandatory controls remove the question of whether the landing zone’s own integrity is protected — it always is, you can’t switch it off, and an auditor can rely on that. Strongly-recommended controls give you an AWS-curated, Well-Architected baseline you can apply estate-wide with confidence. Elective controls let you tighten specific OUs (a regulated-data OU, a PCI OU) without imposing those locks on teams that would only file exemptions. The practical payoff is that you express your control posture as “which categories of control on which OU” — a small, reviewable matrix — instead of evaluating hundreds of individual controls account by account.

It also keeps you aligned with AWS’s roadmap: AWS adds and updates controls over time, and because mandatory/strongly-recommended sets are curated by AWS, enabling them broadly means you inherit improvements rather than maintaining a bespoke catalogue forever.

How to do it well

Enabling a control on an OU

# Discover available controls and their behaviour/category
aws controltower list-controls

# Enable a strongly-recommended control on the Workloads-Prod OU
aws controltower enable-control \
  --control-identifier "arn:aws:controltower:us-east-1::control/AWS-GR_RESTRICTED_SSH" \
  --target-identifier "arn:aws:organizations::123456789012:ou/o-exampleorgid/ou-prod-xxxxxxxx"

# Inspect what's enabled on a target
aws controltower list-enabled-controls \
  --target-identifier "arn:aws:organizations::123456789012:ou/o-exampleorgid/ou-prod-xxxxxxxx"

Reading control IDs: AWS-GR_ vs the Control Catalog (CT. / SH.)

When you enable a control by API you reference it by an identifier, and Control Tower is mid-migration between two naming schemes — knowing both saves real confusion:

Never hardcode a memorised ID — enumerate them, because AWS adds and renames controls continuously:

# The modern, cross-service catalogue (behaviour, category, framework mappings)
aws controlcatalog list-controls

# What Control Tower can enable, with their identifiers/behaviours
aws controltower list-controls

# What is actually enabled on one OU right now
aws controltower list-enabled-controls \
  --target-identifier "arn:aws:organizations::123456789012:ou/o-exampleorgid/ou-prod-xxxxxxxx"

How one “guardrail” maps to a mechanism

The single most clarifying idea in this whole lesson: a control is a requirement; its behaviour is when it acts; its mechanism is how. One friendly requirement can exist as different behaviours with different IDs. Read this row-by-row — the same intent, three different implementations and timings:

Requirement Behaviour Mechanism Example ID Timing
No SSH from 0.0.0.0/0 Detective AWS Config rule (via Security Hub) AWS-GR_RESTRICTED_SSH / SH.EC2.13 After the SG exists
S3 bucket must block public access Proactive CloudFormation Hook CT.S3.PV.1 Before create, in-pipeline
S3 bucket must not be publicly readable Detective AWS Config rule AWS-GR_S3_BUCKET_PUBLIC_READ_PROHIBITED / SH.S3.1 After create
Don’t disable CloudTrail (mandatory) Preventive Service Control Policy aws-guardrails-* (CT-owned) At the API call
EBS volumes must be encrypted Detective AWS Config rule AWS-GR_ENCRYPTED_VOLUMES After the volume exists
Root user must have MFA Detective Config / Security Hub AWS-GR_ROOT_ACCOUNT_MFA_ENABLED / SH.IAM.9 Periodic evaluation

Notice that “block public S3” appears twice — once as a proactive CT.S3.PV.1 hook that stops the bad bucket at deploy time, and once as a detective AWS-GR_S3_BUCKET_PUBLIC_READ_PROHIBITED rule that catches one created by a path the hook can’t see (console, API, Terraform). That is not redundancy; it is defence in depth across behaviours. When you enable a Control Tower control, AWS transparently writes the underlying SCP, Config rule, or Hook into the right accounts and owns its lifecycle — you never hand-edit the generated aws-guardrails-* SCP or the managed Config rule, exactly as with the preventive controls earlier. Your custom SCPs, conformance packs, and Guard hooks sit alongside the managed ones, filling gaps the library doesn’t.

Category vs behaviour: how they combine

Preventive (SCP) Detective (Config) Proactive (Hook)
Mandatory Protect CloudTrail/Config config; protect log roles Detect missing CloudTrail; log-bucket public access (Fewer; integrity-focused)
Strongly recommended Region/IMDSv2 hardening (as enabled) Public S3, open SSH/RDP, unencrypted EBS Require encryption / no-public-access on create
Elective Disallow specific cross-account actions Detect optional posture items Block creation of specific non-compliant types

Concrete artifacts, decisions, and tools

Going deeper

Where SCPs and RCPs sit in policy evaluation

To place guardrails correctly you have to know exactly where they enter IAM’s decision. For any request, access is denied by default, and the allow only survives if it clears every gate:

  1. An explicit Deny anywhere — identity policy, resource policy, session policy, permissions boundary, SCP, or RCP — wins immediately and ends evaluation.
  2. The action must be allowed by an SCP at every level of the org tree from the root down to the account. SCPs do not grant; they permit. This is why deny-lists work: FullAWSAccess supplies the Allow * at each level, and your Deny statements subtract. Remove FullAWSAccess from one OU without an explicit allow, and everything below it is denied even if IAM allows it.
  3. The action must be allowed by an RCP for the resource being touched (if RCPs apply to that service), and by any permissions boundary on the principal.
  4. Finally, at least one identity or resource policy must Allow the action.

The mental shortcut: SCP/RCP/boundary can only ever take permissions away; identity/resource policies are the only things that add them; and any single explicit deny beats every allow. An SCP that “allows” s3:* grants nobody anything — it merely declines to subtract S3. Internalising this stops the most common SCP bug: expecting an SCP to give access.

RCPs and the data perimeter — the newer half

Resource Control Policies reached GA in November 2024 and are the resource-side mirror of SCPs. An SCP bounds what your principals can do; an RCP bounds what any principal, including external ones can do to your resources. That makes RCPs the clean way to build a data perimeter — “no resource in this org may be accessed by an identity outside o-exampleorgid, except approved AWS services.” Key facts that trip people up: RCPs have their own baseline policy, RCPFullAWSAccess, and (at GA) they apply to a limited set of services — Amazon S3, AWS STS, AWS KMS, Amazon SQS, and AWS Secrets Manager — expanding over time, so an RCP does not protect every resource type yet. Like SCPs they attach to root/OU/account and inherit down. A canonical org-perimeter RCP denies access unless aws:PrincipalOrgID equals your org, with a aws:PrincipalIsAWSService exemption so AWS services (like CloudFront’s OAC reading an S3 origin) still work.

Layer interaction: who fires first, and the short-circuit

The three behaviours don’t run in parallel — they gate different moments, and an earlier gate short-circuits a later one. A preventive SCP is evaluated at the API call itself, so if the SCP denies cloudformation:CreateStack, the proactive hook never even runs — there is no stack operation to inspect. If the SCP allows the call, the proactive hook runs next, at provisioning, and can still FAIL the stack on a property the SCP couldn’t see. Only if both let the resource exist does the detective Config rule later evaluate its recorded state. Design with that order in mind: put “never, at all” requirements in the SCP (cheapest, earliest, universal), property-level “not like this” requirements in the hook, and drift/audit requirements in Config. Putting a requirement at the wrong layer — e.g. relying on a detective rule for something that needs zero-window prevention — is the single most common architecture mistake here.

Drift, and how Control Tower thinks about it

Because Control Tower owns the SCPs, Config rules, and hooks behind its managed controls, any out-of-band change is drift, and Control Tower surfaces several kinds: a managed aws-guardrails-* SCP edited or detached by hand; an OU or account moved outside Control Tower; a member account’s Config recorder or CloudTrail altered; or the landing zone falling behind the latest Control Tower version. The cure is almost always “re-register” or “repair” from the Control Tower console/API rather than manual patching — hand-fixing a managed SCP just creates more drift. The discipline that prevents drift is simple: never touch the machinery Control Tower generates; layer your own controls beside it. Your custom SCPs and packs have their own drift story — detect it by diffing the deployed policy against Git in CI, since Organizations has no native “this SCP was changed” alarm beyond CloudTrail events (organizations:UpdatePolicy, AttachPolicy, DetachPolicy), which you should route to EventBridge.

Testing guardrails without causing an outage

SCP mistakes cause outages precisely because they are silent and universal, so treat every new deny as a change with a blast radius:

Quotas, scale, and cost — the numbers that bite at 100+ accounts

Security and governance nuances

Real-world enterprise scenario

Meridian Logistics, a freight and supply-chain company, runs a Control Tower landing zone with 140 accounts across a familiar OU hierarchy: Security (Log Archive, Audit), Infrastructure (shared networking, CI/CD), WorkloadsProd / NonProd, PCI (their payments and customer-billing systems), EU (data that must stay in-region under GDPR), and Sandbox. A SOC 2 audit and a new EU data-residency obligation force them to make the guardrail layer real and provable. They work it sub-component by sub-component.

Preventive (SCPs). They keep FullAWSAccess at the root and author six dense deny-list SCPs as code in a org-scps repo, deployed by Terraform. At the root: an SCP denying organizations:LeaveOrganization, an SCP protecting CloudTrail/Config and the Log Archive buckets, and a root-user hardening SCP that denies all root activity except a documented break-glass role. On the EU OU: a region-lock SCP using aws:RequestedRegion restricted to eu-west-1/eu-central-1, with a NotAction exemption for IAM, Organizations, Route 53, CloudFront, STS, Support, and an aws:PrincipalArn carve-out for the Control Tower execution role. On Workloads: an IMDSv2-required SCP (ec2:RunInstances denied unless ec2:MetadataHttpTokens = required) and a security-services-protection SCP (Deny guardduty:Delete*, securityhub:Disable*). They hit the 5-SCP-per-target ceiling on Workloads and consolidate two drafts into one. They also adopt a single RCP at the root denying any S3/KMS access from principals outside aws:PrincipalOrgID (with approved-service exemptions) to close the resource perimeter. Every policy ships through CI with an IAM Policy Simulator check and a non-prod-OU canary before promotion.

Detective (Config rules). They enable Control Tower’s strongly-recommended detective controls estate-wide and add a custom organization conformance pack mapped to CIS AWS Foundations Benchmark v3.0 plus AWS Foundational Security Best Practices, deployed from the Audit account (delegated Config administrator) so it lands on all 140 accounts and every future one. They stand up a Config aggregator in Audit for a single org-wide compliance view, route findings to Security Hub, and tune the configuration recorder to skip a few high-churn resource types to keep the Config bill predictable. Low-risk rules (S3 default-encryption, public-access-block, missing tags) get automatic SSM Automation remediation; security-group changes get manual-approval remediation so they never disrupt a running app.

Proactive (CloudFormation Hooks). Because all Prod and PCI infrastructure deploys through Service Catalog (CDK-synthesised CloudFormation), Meridian enables Control Tower’s managed proactive controls there — “require encryption” and “disallow public access” on S3, RDS, and EBS — so a non-compliant resource is blocked at stack-create time with zero non-compliant window. They author one custom cfn-guard hook asserting a mandatory DataClassification tag and a log-retention minimum, land it in WARN mode for two sprints to socialise it (it would have blocked 38 deploys), then flip it to FAIL. NonProd and Sandbox, which still allow some Terraform, rely on the detective pack plus a cfn-guard/OPA check in their Terraform CI as the moral equivalent.

Categories. Mandatory controls stay untouched. Strongly-recommended controls are enabled on the Workloads OU root (covering Prod and NonProd and every future child). Elective controls are applied surgically: “disallow S3 bucket deletion” and “disallow versioning changes” on the PCI OU (records retention), and a Sandbox service-allowlist SCP keeps experimentation cheap and safe. The whole posture is captured in a one-page control-enablement matrix (category → OU) plus the SCP-attachment matrix, both in Git.

Outcome. Within one quarter: org-wide Config compliance moved from 71% to 96% with the remaining 4% tracked as accepted exemptions; the EU region-lock made GDPR data-residency mechanically provable (auditors were shown the SCP, not a spreadsheet); zero unencrypted S3/EBS resources entered Prod after the proactive hooks went to FAIL; the SOC 2 audit’s “logging integrity” and “encryption at rest” controls were evidenced directly from mandatory controls and the conformance pack with no manual screenshotting; and exemption tickets dropped because most violations were now caught in-pipeline by developers rather than in production by the platform team.

Deliverables & checklist

Common pitfalls

  1. Region-locking yourself out of IAM. A blanket aws:RequestedRegion deny without exempting global services (which authenticate through us-east-1) bricks IAM, Organizations, and Route 53 administration. Always pair the region lock with a NotAction exemption list and test in a non-prod OU first.
  2. Running workloads — or anything — in the management account. SCPs do not apply to the management account, so nothing you put there can be fenced in, and it has org-admin blast radius. Keep it empty of workloads; the landing zone’s whole trust model assumes this.
  3. Editing Control Tower’s managed SCPs by hand. The aws-guardrails-* policies are owned by Control Tower; manual edits cause drift, non-compliant OU status, and may be auto-reverted. Add your own SCPs alongside them instead.
  4. Treating detective controls as if they prevented anything. Config flags drift after the fact, with a detect/remediate lag. If a requirement cannot tolerate a non-compliant window, it needs a preventive (SCP) or proactive (hook) control — not a Config rule alone.
  5. Assuming proactive hooks cover everything. CloudFormation Hooks only fire for CloudFormation/Service Catalog/CDK deployments — never for console, raw API, or Terraform. Layer detective + preventive controls to cover the un-hooked paths, or replicate the Guard checks in your Terraform CI.
  6. Hitting SCP limits late. Discovering the 5-per-target / 5,120-character ceilings after you’ve drafted ten single-purpose policies forces a painful rewrite. Design dense, consolidated, well-commented SCPs from the start, and use NotAction/NotResource to stay compact.

Practice challenges

Work these in order — they escalate from “name the behaviour” to “consolidate under the SCP cap.” Every account ID is a placeholder; do not run anything against a real org without a sandbox OU.

1 (Beginner) — Pick the behaviour. A requirement says: “No S3 bucket may ever be created via CloudFormation without encryption — with zero window in which a non-compliant bucket exists.” Which of the three behaviours and which mechanism satisfies it, and why not the other two?

<details> <summary>Solution</summary>

Proactive, delivered by a CloudFormation Hook (a cfn-guard rule such as CT.S3.PV.*). It inspects the proposed bucket properties before creation and fails the stack, so there is no non-compliant window. A detective Config rule would let the bucket exist first (a window); a preventive SCP can block s3:CreateBucket but can’t see the encryption property, so it’s too blunt.

Why: zero-window + property-level = proactive, in-pipeline. </details>

2 (Beginner) — Seal the org. Write the SCP that stops any member account from leaving the organization, and say where you attach it.

<details> <summary>Solution</summary>

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyLeaveOrganization",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    }
  ]
}

Attach at the root so it covers every current and future account.

Why: an account that can leave the org escapes every other guardrail, so this is the most universal deny. </details>

3 (Intermediate) — Region lock without a lockout. Write a region-deny SCP that confines an OU to ap-south-1, keeps global services working, and exempts the Control Tower execution role.

<details> <summary>Solution</summary>

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideApSouth1",
      "Effect": "Deny",
      "NotAction": [
        "iam:*", "organizations:*", "sts:*", "route53:*",
        "cloudfront:*", "waf:*", "wafv2:*", "shield:*",
        "support:*", "globalaccelerator:*", "budgets:*",
        "ce:*", "cur:*", "health:*", "trustedadvisor:*",
        "artifact:*", "account:*", "ec2:DescribeRegions"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": { "aws:RequestedRegion": ["ap-south-1"] },
        "ArnNotLike": {
          "aws:PrincipalArn": ["arn:aws:iam::*:role/AWSControlTowerExecution"]
        }
      }
    }
  ]
}

Why: NotAction keeps global (us-east-1-authenticated) services alive, and the ArnNotLike carve-out stops the lock from breaking Control Tower automation. </details>

4 (Intermediate) — Close the loop with remediation. You have the managed Config rule s3-bucket-public-read-prohibited. Add the conformance-pack snippet that auto-remediates a public bucket, and say when you would make it manual instead.

<details> <summary>Solution</summary>

  S3PublicReadRemediation:
    Type: AWS::Config::RemediationConfiguration
    Properties:
      ConfigRuleName: s3-bucket-public-read-prohibited
      TargetType: SSM_DOCUMENT
      TargetId: AWS-DisableS3BucketPublicReadWrite
      Automatic: true
      MaximumAutomaticAttempts: 3
      RetryAttemptSeconds: 60
      Parameters:
        AutomationAssumeRole:
          StaticValue:
            Values: ["arn:aws:iam::123456789012:role/ConfigRemediationRole"]
        S3BucketName:
          ResourceValue:
            Value: RESOURCE_ID

Make it Automatic: false (manual approval) when the fix could disrupt a live workload — e.g. tightening a security group on a running app.

Why: auto-remediate idempotent, low-risk fixes; require a human where the fix has blast radius. </details>

5 (Advanced) — Beat the 5-SCP cap. The Workloads OU already has 5 SCPs, and you have three more single-purpose drafts: deny-leave-org, deny-root, and deny-disabling-GuardDuty. Consolidate them into one document.

<details> <summary>Solution</summary>

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyLeaveOrg",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    },
    {
      "Sid": "DenyRootUser",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": { "StringLike": { "aws:PrincipalArn": "arn:aws:iam::*:root" } }
    },
    {
      "Sid": "DenyDisableGuardDuty",
      "Effect": "Deny",
      "Action": ["guardduty:Delete*", "guardduty:Disassociate*", "guardduty:Stop*"],
      "Resource": "*"
    }
  ]
}

Three Sids in one policy = one SCP against the cap. Strip whitespace and prefer NotAction/NotResource to stay under 5,120 characters.

Why: the limit is 5 policies per target, not 5 statements — dense multi-statement policies are how you scale within it. </details>

6 (Advanced) — Map a requirement end to end. For “no SSH from the internet,” give (a) the detective control’s legacy ID and mechanism, (b) how you’d enable it as code on the Workloads-Prod OU, and © what would give you zero-window prevention instead — and why Control Tower may not ship it.

<details> <summary>Solution</summary>

(a) AWS-GR_RESTRICTED_SSH (also surfaced as the Security Hub control SH.EC2.13), implemented as an AWS Config rule — detective, so it flags an offending security group after it exists.

(b) Enable it on the OU as code:

aws controltower enable-control \
  --control-identifier "arn:aws:controltower:us-east-1::control/AWS-GR_RESTRICTED_SSH" \
  --target-identifier "arn:aws:organizations::123456789012:ou/o-exampleorgid/ou-prod-xxxxxxxx"

© Zero-window prevention means acting before the rule exists: a proactive CloudFormation Hook (cfn-guard) asserting no SecurityGroupIngress allows 0.0.0.0/0 on port 22, or a preventive SCP denying ec2:AuthorizeSecurityGroupIngress under a condition. Control Tower leans on the detective version because an SCP can’t easily read the CIDR/port shape of the ingress request and a hook only covers CloudFormation paths — so the practical baseline is detect-and-alert, hardened per-OU with a hook where IaC is the deploy path.

Why: one requirement, three possible behaviours and IDs — choosing among them is exactly the behaviour/mechanism/timing trade-off this lesson is about. </details>

Common beginner mistakes

These are conceptual traps — the wrong mental model — distinct from the operational Common pitfalls above.

  1. “An SCP grants permission.” It never does. An SCP only sets a ceiling; the principal still needs an IAM Allow, and the effective permission is the intersection of the two. Right model: SCP/RCP subtract, IAM adds, any explicit deny wins.
  2. “SCPs protect the management account.” They don’t apply to it at all — nor to service-linked roles. Right model: keep the management account empty of workloads and guard who can sign into it, because that is where guardrails can be rewritten.
  3. “A guardrail is one thing.” A control is a requirement; it is delivered by a behaviour (preventive/detective/proactive) using a mechanism (SCP/Config/Hook), and sorted by a category (mandatory/strongly-recommended/elective). Right model: think in those three axes, not “a guardrail.”
  4. “Detective controls stop bad things.” Config detects after the fact, with a detect-and-remediate lag. Right model: if the requirement can’t tolerate any non-compliant window, you need preventive or proactive — detection is a complement, not a substitute.
  5. “Enable controls per account.” Enabling on individual accounts means every new account starts non-compliant until someone remembers. Right model: enable at the OU so current and future accounts inherit on day zero.
  6. AWS-GR_ IDs are the only/permanent names.” They’re the legacy scheme; the Control Catalog now issues CT.* (proactive) and SH.* (detective) IDs, and the library changes constantly. Right model: enumerate with list-controls, don’t hardcode a memorised ID.
  7. “Proactive hooks cover everything.” A CloudFormation Hook only fires for CloudFormation / Service Catalog / CDK. Console, raw API, and Terraform bypass it entirely. Right model: layer preventive + detective to cover the un-hooked paths.

Glossary

What’s next

With guardrails enforced through preventive SCPs, detective Config rules, proactive Hooks, and the right mix of mandatory/strongly-recommended/elective controls, Part 5 of AWS Landing Zone & Control Tower turns to account vending and customization at scale — automating compliant account provisioning with Account Factory and Account Factory for Terraform (AFT) so every new account is born inside these guardrails.

AWSLanding ZoneGuardrails (SCPs & Controls)Enterprise
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments