Service Control Policies (SCPs) are organizational guardrails. They set the maximum permissions for accounts in your organization - even if an IAM policy or permission set would grant access, an SCP can deny it.
Here are the baseline SCPs we deploy, common patterns worth adopting, and the gotchas that catch people out.
How SCPs Work
SCPs don't grant permissions. They set boundaries on what permissions can be granted:
Think of it as a filter (see the SCP evaluation logic for the full picture):
- IAM policy says "allow S3 full access"
- SCP says "deny S3 in eu-west-3"
- Result: S3 access everywhere except eu-west-3
The effective permission is the intersection of all policies.
SCP Inheritance
SCPs follow the organizational hierarchy:
Root (FullAWSAccess + RestrictRegions + DenyRoot + ProtectCloudTrail)
├── Production OU (+ RequireEncryption)
│ └── website-prod account (inherits all five)
└── Infrastructure OU (no additional SCPs)
└── dns account (inherits root's four)An account's effective permissions are limited by:
- SCPs attached to the root
- SCPs attached to parent OUs
- SCPs attached to the account directly
More restrictive policies at higher levels affect all descendants.
The FullAWSAccess Policy
Every organization starts with FullAWSAccess attached to the root:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}This allows everything by default. You add restrictive SCPs to narrow permissions.
Never remove FullAWSAccess without a replacement Allow policy. If you do, all actions in all accounts will be denied.
Common SCP Patterns
Restrict Regions
Limit resources to approved regions:
resource "aws_organizations_policy" "restrict_regions" {
name = "RestrictRegions"
description = "Restrict operations to approved regions"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnapprovedRegions"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
StringNotEquals = {
"aws:RequestedRegion" = [
"eu-west-2",
"eu-west-1",
"us-east-1" # Required for global services
]
}
# Exclude global services that don't support regions
"ForAllValues:StringNotEquals" = {
"aws:PrincipalServiceName" = [
"cloudfront.amazonaws.com",
"iam.amazonaws.com",
"route53.amazonaws.com",
"organizations.amazonaws.com",
"support.amazonaws.com"
]
}
}
}
]
})
}This prevents creating resources in unapproved regions - useful for data residency compliance.
In real deployments, region policies usually need a few explicit carve-outs. Ours did too:
| Exception type | Why it was needed |
|---|---|
us-east-1 control plane calls | Some global service workflows still rely on it |
| Global service principals | Services like IAM/CloudFront/Route 53 are not regional in the same way as EC2 |
Expect one or two rollout iterations before the allowlist settles.
Prevent Root User Access
Deny all actions by root users (except in management account):
resource "aws_organizations_policy" "deny_root" {
name = "DenyRootUserAccess"
description = "Prevent root user access in member accounts"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyRootUser"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
StringLike = {
"aws:PrincipalArn" = "arn:aws:iam::*:root"
}
}
}
]
})
}Note: This doesn't apply to the management account - SCPs never affect the management account.
Protect CloudTrail
Prevent disabling or modifying audit logging:
resource "aws_organizations_policy" "protect_cloudtrail" {
name = "ProtectCloudTrail"
description = "Prevent modification of CloudTrail logging"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ProtectCloudTrail"
Effect = "Deny"
Action = [
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors"
]
Resource = "*"
Condition = {
StringNotLike = {
"aws:PrincipalArn" = [
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
"arn:aws:iam::*:role/IdentityManagementDeploymentRole"
]
}
}
}
]
})
}The condition allows specific deployment roles to manage CloudTrail while denying everyone else.
Require Encryption
Deny creation of unencrypted resources:
resource "aws_organizations_policy" "require_encryption" {
name = "RequireEncryption"
description = "Require encryption on storage resources"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedS3"
Effect = "Deny"
Action = "s3:PutObject"
Resource = "*"
Condition = {
Null = {
"s3:x-amz-server-side-encryption" = "true"
}
}
},
{
Sid = "DenyUnencryptedEBS"
Effect = "Deny"
Action = "ec2:CreateVolume"
Resource = "*"
Condition = {
Bool = {
"ec2:Encrypted" = "false"
}
}
}
]
})
}Attaching SCPs
SCPs can attach to:
- The organization root (affects all accounts except management)
- Organizational units (affects accounts in that OU and child OUs)
- Individual accounts
resource "aws_organizations_policy_attachment" "restrict_regions_production" {
policy_id = aws_organizations_policy.restrict_regions.id
target_id = aws_organizations_organizational_unit.production.id
}The Baseline Set
For this setup, we deploy four SCPs as a baseline and attach them to the appropriate OUs:
# Region restriction on all member accounts
resource "aws_organizations_policy_attachment" "restrict_regions_root" {
policy_id = aws_organizations_policy.restrict_regions.id
target_id = data.aws_organizations_organization.org.roots[0].id
}
# Root user denial on all member accounts
resource "aws_organizations_policy_attachment" "deny_root_root" {
policy_id = aws_organizations_policy.deny_root.id
target_id = data.aws_organizations_organization.org.roots[0].id
}
# Protect audit logging on all member accounts
resource "aws_organizations_policy_attachment" "protect_cloudtrail_root" {
policy_id = aws_organizations_policy.protect_cloudtrail.id
target_id = data.aws_organizations_organization.org.roots[0].id
}
# Require encryption on production accounts only
resource "aws_organizations_policy_attachment" "require_encryption_production" {
policy_id = aws_organizations_policy.require_encryption.id
target_id = aws_organizations_organizational_unit.production.id
}The first three attach to the organization root, so every member account inherits them. The encryption requirement applies only to the Production OU - infrastructure accounts may need more flexibility during bootstrap.
Rollout Strategy
SCPs can lock you out of accounts if misconfigured. We follow a staged rollout:
- Define the SCP but don't attach it
- Create a test OU with a non-critical account
- Attach and verify workloads still function
- Expand to production OUs once validated
- Monitor CloudTrail for
AccessDeniedevents that indicate the SCP is too restrictive
For this setup, the region restriction and root user denial are safe to attach immediately - they're well-understood patterns. The CloudTrail protection and encryption requirements need more care, particularly around the deployment role exceptions.
SCP Gotchas
Management Account Immunity
SCPs never affect the management account. This is by AWS design - you can't lock yourself out of organizational management.
This also means: don't run workloads in the management account, because SCPs won't protect it.
Deny-Only Can Surprise You
A common mistake:
{
"Effect": "Deny",
"Action": "ec2:*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"ec2:InstanceType": ["t3.micro", "t3.small"]
}
}
}Intention: Only allow t3.micro and t3.small instances.
Reality: Denies all EC2 actions that don't involve instance types (like describing VPCs), because ec2:InstanceType is null for those actions.
Solution: Scope the action to ec2:RunInstances specifically.
SCPs vs IAM Policies
| Aspect | SCP | IAM Policy |
|---|---|---|
| Scope | Organization/OU/Account | Principal |
| Effect | Limits maximum permissions | Grants permissions |
| Management account | Never applies | Applies normally |
| Use case | Guardrails, compliance | Day-to-day access |
Use SCPs for organization-wide rules. Use IAM policies for specific access grants.
Beyond the Baseline
Once the four core SCPs are in place, common next steps include:
RequireIMDSv2: Enforce Instance Metadata Service v2 for EC2 instances, blocking the insecure v1 endpoint:
resource "aws_organizations_policy" "require_imdsv2" {
name = "RequireIMDSv2"
description = "Require EC2 instances to use IMDSv2"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "RequireIMDSv2"
Effect = "Deny"
Action = "ec2:RunInstances"
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
StringNotEquals = {
"ec2:MetadataHttpTokens" = "required"
}
}
}
]
})
}DenyLeavingOrganization: Prevent member accounts from removing themselves:
{
"Effect": "Deny",
"Action": "organizations:LeaveOrganization",
"Resource": "*"
}ProtectConfig: Similar to CloudTrail protection, prevent disabling AWS Config.
The pattern is always the same: define a clear boundary, test in isolation, then attach to the appropriate level in the hierarchy. The SCP examples library has many more patterns to draw from.
Summary
SCPs provide the guardrails that make a multi-account setup trustworthy:
- They set permission ceilings, not grants
- Start with the four-SCP baseline: region restriction, root user denial, audit log protection, and encryption requirements
- Test thoroughly before attaching to production OUs
- Expand coverage as compliance requirements evolve
The investment is small - a handful of policies - but the protection is real. A misconfigured IAM policy in a member account can't disable CloudTrail, can't create resources in unapproved regions, and can't use the root user.
What's Next
With organisational boundaries enforced, the next piece is DNS. Part 10 covers Route 53 zone management, email configuration patterns, cross-account DNS access for certificate validation, and the CI/CD pipeline for DNS changes.