AWS Organizations: Service Control Policies as Account Guardrails
Scenario
Your organisation uses AWS Organizations with three OUs: Production, Development, and Sandbox. Your security team needs preventative guardrails that no individual IAM policy can override: production accounts must never allow root user access, all accounts must stay in a single approved region list, sandbox accounts must be blocked from expensive services, and no account can disable CloudTrail.
- Create four SCPs: DenyRootAccess, AllowedRegions (us-east-1 and eu-west-1 only), DenyCloudTrailDisable, and SandboxDenyExpensiveServices (blocks EC2 instance types larger than t3.xlarge and RDS instance classes larger than db.t3.medium).
- Look up the existing Organization root and OUs using data sources. Do not create the Organization or OUs — these already exist.
- Attach DenyRootAccess, AllowedRegions, and DenyCloudTrailDisable to the Organization root so they apply to all accounts.
- Attach SandboxDenyExpensiveServices only to the Sandbox OU.
- Output all SCP IDs and the OU the sandbox SCP is attached to.
Additional Context
SCPs use an implicit deny all + explicit allow model. When an SCP is attached, only the actions explicitly Allowed in the SCP are permitted — even if an IAM policy grants broader access. However, SCPs do not apply to the management account of the organization — this is a common gotcha.
The AllowedRegions SCP must use a Deny statement with a StringNotEquals condition on aws:RequestedRegion and must include an exemption (using ArnNotLike on aws:PrincipalArn) for global/IAM actions that don't have a region (e.g., IAM, STS, CloudFront). Without this exemption, the SCP will block users from logging in.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "sandbox_ou_id" { type = string; description = "Organizational Unit ID for the Sandbox OU." }
variable "allowed_regions" {
type = list(string)
default = ["us-east-1", "eu-west-1"]
description = "AWS regions where workloads are permitted."
}main.tf
provider "aws" { region = var.aws_region }
# Look up the Organization root — SCPs at root level apply to ALL accounts.
data "aws_organizations_organization" "main" {}
# SCP 1: Deny root user access.
resource "aws_organizations_policy" "deny_root" {
name = "DenyRootAccess"
description = "Prevent root user from performing any API actions."
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyRootAccess"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = { ArnEquals = { "aws:PrincipalArn" = ["arn:aws:iam::*:root"] } }
}]
})
}
# SCP 2: Restrict workloads to approved regions.
resource "aws_organizations_policy" "allowed_regions" {
name = "AllowedRegions"
description = "Deny API calls to regions outside the approved list."
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyOutsideApprovedRegions"
Effect = "Deny"
NotAction = [
# These services are global and must be exempted from region restriction.
"iam:*","sts:*","cloudfront:*","route53:*","support:*","budgets:*"
]
Resource = "*"
Condition = {
StringNotEquals = { "aws:RequestedRegion" = var.allowed_regions }
}
}]
})
}
# SCP 3: Prevent CloudTrail from being disabled.
resource "aws_organizations_policy" "deny_cloudtrail_disable" {
name = "DenyCloudTrailDisable"
description = "Prevent anyone from stopping, deleting, or updating CloudTrail."
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyCloudTrailModifications"
Effect = "Deny"
Action = [
"cloudtrail:DeleteTrail","cloudtrail:StopLogging",
"cloudtrail:UpdateTrail","cloudtrail:PutEventSelectors"
]
Resource = "*"
}]
})
}
# SCP 4: Block expensive compute/database in Sandbox — restrict instance types.
resource "aws_organizations_policy" "sandbox_limits" {
name = "SandboxDenyExpensiveServices"
description = "Block large EC2 and RDS instance types in Sandbox accounts."
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyLargeEC2"
Effect = "Deny"
Action = ["ec2:RunInstances"]
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
StringNotLike = {
"ec2:InstanceType" = ["t2.*","t3.*","t3a.*","m5.large","m5.xlarge"]
}
}
},
{
Sid = "DenyLargeRDS"
Effect = "Deny"
Action = ["rds:CreateDBInstance","rds:ModifyDBInstance"]
Resource = "*"
Condition = {
StringNotLike = { "rds:DatabaseClass" = ["db.t3.*","db.t4g.*"] }
}
}
]
})
}
# Attach global SCPs to the Organization root.
resource "aws_organizations_policy_attachment" "root_deny_root" {
policy_id = aws_organizations_policy.deny_root.id
target_id = data.aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_policy_attachment" "root_allowed_regions" {
policy_id = aws_organizations_policy.allowed_regions.id
target_id = data.aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_policy_attachment" "root_deny_cloudtrail" {
policy_id = aws_organizations_policy.deny_cloudtrail_disable.id
target_id = data.aws_organizations_organization.main.roots[0].id
}
# Attach sandbox-only SCP to the Sandbox OU only.
resource "aws_organizations_policy_attachment" "sandbox_limits" {
policy_id = aws_organizations_policy.sandbox_limits.id
target_id = var.sandbox_ou_id
}outputs.tf
output "deny_root_scp_id" { value = aws_organizations_policy.deny_root.id }
output "allowed_regions_scp_id" { value = aws_organizations_policy.allowed_regions.id }
output "deny_cloudtrail_scp_id" { value = aws_organizations_policy.deny_cloudtrail_disable.id }
output "sandbox_limits_scp_id" { value = aws_organizations_policy.sandbox_limits.id }
output "org_root_id" { value = data.aws_organizations_organization.main.roots[0].id }Workflow Commands
terraform init
terraform apply -var="sandbox_ou_id=ou-xxxx-yyyyyyy"
# List all SCPs attached to root
aws organizations list-policies-for-target \
--target-id $(terraform output -raw org_root_id) \
--filter SERVICE_CONTROL_POLICY \
--query 'Policies[*].{Name:Name,Id:Id}'
# Verify sandbox OU has the extra SCP
aws organizations list-policies-for-target \
--target-id ou-xxxx-yyyyyyy \
--filter SERVICE_CONTROL_POLICY
# Test: attempt to run a large instance in a sandbox account (should be denied)
aws ec2 run-instances --image-id ami-xxxx --instance-type m5.4xlarge
# Expected: An error occurred (AccessDenied) ...
terraform destroy -var="sandbox_ou_id=ou-xxxx-yyyyyyy"Apply complete! Resources: 8 added, 0 changed, 0 destroyed. Outputs: allowed_regions_scp_id = "p-aaaaa..." deny_cloudtrail_scp_id = "p-bbbbb..." deny_root_scp_id = "p-ccccc..." org_root_id = "r-xxxx" sandbox_limits_scp_id = "p-ddddd..."

