AWS Service Control Policies (SCPs) - Guardrails for Your Organization
Someone in your organisation spins up resources in a region you don’t operate in. Someone enables a service that’s not approved. Someone creates IAM users when you only allow federated access. By the time you find out, it’s been running for weeks.
Service Control Policies (SCPs) prevent this by setting permission boundaries at the organisation level. Even if an IAM policy grants full admin access, the SCP can block specific actions, regions, or services. They’re guardrails - not grants.
This post covers how SCPs work, evaluation logic, common patterns, and production-ready Terraform examples.
TL;DR
- SCPs set maximum permissions - they don’t grant access, they limit it
- Apply to all IAM users and roles in member accounts (not the management account)
- Use deny-list strategy for most cases (allow everything, deny specific things)
- Allow-list strategy for high-security environments (deny everything except explicit allows)
- Always test in a sandbox OU before applying to production
- Service-linked roles are exempt from SCPs
Code Repository: All code from this post is available at github.com/moabukar/blog-code/aws-scps
How SCPs Work
SCPs don’t grant permissions. They define the maximum available permissions for accounts in your organisation. Think of them as a ceiling, not a floor.
┌─────────────────────────────────────────────────────────────────┐
│ Permission Evaluation │
└─────────────────────────────────────────────────────────────────┘
SCP IAM Policy Effective Permission
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Maximum │ ∩ │ Granted │ = │ Actual │
│ Permissions │ │ Permissions │ │ Access │
│ │ │ │ │ │
│ (Guardrail) │ │ (Policy) │ │ (Result) │
└─────────────┘ └─────────────┘ └─────────────┘
Example: If an SCP denies S3 access, but an IAM policy grants S3 full access, the user cannot access S3. The SCP wins.
Key points:
- SCPs apply to member accounts only - not the management account
- SCPs affect all IAM users and roles, including the root user
- Service-linked roles are exempt from SCPs
- SCPs don’t affect resource-based policies for external principals
SCP Evaluation Logic
SCPs are evaluated at every level from root to account. For an action to be allowed:
- Allow strategy: Every level must explicitly allow it
- Deny strategy: No level can explicitly deny it
┌──────────────────┐
│ Root │
│ FullAWSAccess │
└────────┬─────────┘
│
┌────────────────┴────────────────┐
│ │
┌────────┴────────┐ ┌───────┴────────┐
│ Production │ │ Sandbox │
│ FullAWSAccess │ │ FullAWSAccess │
│ + Deny Regions │ │ │
└────────┬────────┘ └───────┬────────┘
│ │
┌────────┴────────┐ ┌───────┴────────┐
│ Account A │ │ Account B │
│ (inherits deny) │ │ (full access) │
└─────────────────┘ └────────────────┘
Account A inherits the region restriction from Production OU. Account B has full access.
Deny-List vs Allow-List Strategy
Deny-List Strategy (Recommended for most cases)
Start with FullAWSAccess, then deny specific actions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnusedRegions",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"eu-west-1",
"eu-west-2",
"us-east-1"
]
}
}
}
]
}
Pros:
- New services automatically allowed
- Less maintenance
- Easier to implement
Cons:
- New services might be used before you evaluate them
Allow-List Strategy (High-security environments)
Replace FullAWSAccess with explicit allows:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowApprovedServices",
"Effect": "Allow",
"Action": [
"ec2:*",
"s3:*",
"rds:*",
"lambda:*",
"cloudwatch:*",
"logs:*",
"iam:*",
"kms:*"
],
"Resource": "*"
}
]
}
Pros:
- New services blocked by default
- Tighter security posture
Cons:
- Requires ongoing maintenance
- New services cause friction
Common SCP Patterns
1. Restrict Regions
Prevent resource creation outside approved regions:
resource "aws_organizations_policy" "deny_unapproved_regions" {
name = "deny-unapproved-regions"
description = "Deny all actions outside approved regions"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnapprovedRegions"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
StringNotEquals = {
"aws:RequestedRegion" = var.approved_regions
}
# Exclude global services
"ForAnyValue:StringNotLike" = {
"aws:PrincipalArn" = [
"arn:aws:iam::*:role/aws-service-role/*"
]
}
}
}
]
})
}
variable "approved_regions" {
default = ["eu-west-1", "eu-west-2", "us-east-1"]
}
Important: Some services are global (IAM, Route53, CloudFront) - they operate in us-east-1. Ensure you include it or add exceptions.
2. Prevent Leaving the Organisation
Stop accounts from leaving:
resource "aws_organizations_policy" "deny_leave_org" {
name = "deny-leave-organization"
description = "Prevent accounts from leaving the organization"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyLeaveOrganization"
Effect = "Deny"
Action = "organizations:LeaveOrganization"
Resource = "*"
}
]
})
}
3. Require IMDSv2 for EC2
Block instance launches without IMDSv2:
resource "aws_organizations_policy" "require_imdsv2" {
name = "require-ec2-imdsv2"
description = "Require IMDSv2 for all EC2 instances"
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"
}
}
}
]
})
}
4. Deny Root User Access
Prevent root user from taking actions (except for specific tasks):
resource "aws_organizations_policy" "deny_root_user" {
name = "deny-root-user-actions"
description = "Deny most actions for root user"
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"
}
}
}
]
})
}
5. Protect Security Services
Prevent disabling critical security services:
resource "aws_organizations_policy" "protect_security_services" {
name = "protect-security-services"
description = "Prevent disabling GuardDuty, SecurityHub, CloudTrail"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ProtectGuardDuty"
Effect = "Deny"
Action = [
"guardduty:DeleteDetector",
"guardduty:DeleteMembers",
"guardduty:DisassociateFromMasterAccount",
"guardduty:DisassociateMembers",
"guardduty:StopMonitoringMembers",
"guardduty:UpdateDetector"
]
Resource = "*"
},
{
Sid = "ProtectSecurityHub"
Effect = "Deny"
Action = [
"securityhub:DeleteMembers",
"securityhub:DisableSecurityHub",
"securityhub:DisassociateFromMasterAccount",
"securityhub:DisassociateMembers"
]
Resource = "*"
},
{
Sid = "ProtectCloudTrail"
Effect = "Deny"
Action = [
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors"
]
Resource = "*"
Condition = {
StringLike = {
"aws:ResourceTag/SecurityCritical" = "true"
}
}
}
]
})
}
6. Enforce Encryption
Deny creating unencrypted resources:
resource "aws_organizations_policy" "enforce_encryption" {
name = "enforce-encryption"
description = "Deny creating unencrypted EBS volumes and S3 buckets"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedEBSVolumes"
Effect = "Deny"
Action = "ec2:CreateVolume"
Resource = "*"
Condition = {
Bool = {
"ec2:Encrypted" = "false"
}
}
},
{
Sid = "DenyUnencryptedS3Objects"
Effect = "Deny"
Action = "s3:PutObject"
Resource = "*"
Condition = {
Null = {
"s3:x-amz-server-side-encryption" = "true"
}
}
}
]
})
}
7. Restrict IAM Actions
Prevent dangerous IAM configurations:
resource "aws_organizations_policy" "restrict_iam" {
name = "restrict-iam-actions"
description = "Prevent dangerous IAM configurations"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyIAMUserCreation"
Effect = "Deny"
Action = [
"iam:CreateUser",
"iam:CreateAccessKey"
]
Resource = "*"
},
{
Sid = "DenySAMLProviderModification"
Effect = "Deny"
Action = [
"iam:CreateSAMLProvider",
"iam:DeleteSAMLProvider",
"iam:UpdateSAMLProvider"
]
Resource = "*"
Condition = {
StringNotLike = {
"aws:PrincipalArn" = [
"arn:aws:iam::*:role/IdentityAdminRole"
]
}
}
}
]
})
}
8. Deny Expensive Instance Types
Control costs by restricting instance sizes:
resource "aws_organizations_policy" "deny_expensive_instances" {
name = "deny-expensive-instance-types"
description = "Deny launching expensive EC2 instance types"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyExpensiveInstances"
Effect = "Deny"
Action = "ec2:RunInstances"
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
"ForAnyValue:StringLike" = {
"ec2:InstanceType" = [
"*.metal",
"*.24xlarge",
"*.16xlarge",
"*.12xlarge",
"p*.*", # GPU instances
"inf*.*", # Inferentia
"dl*.*" # Deep learning
]
}
}
}
]
})
}
Full Terraform Module
Here’s a complete module for managing SCPs:
# modules/scps/main.tf
variable "organization_root_id" {
description = "Root ID of the AWS Organization"
type = string
}
variable "production_ou_id" {
description = "Production OU ID"
type = string
}
variable "sandbox_ou_id" {
description = "Sandbox OU ID"
type = string
}
variable "approved_regions" {
description = "List of approved AWS regions"
type = list(string)
default = ["eu-west-1", "eu-west-2", "us-east-1"]
}
# Base policy - deny leaving organization (attach to root)
resource "aws_organizations_policy" "deny_leave_org" {
name = "deny-leave-organization"
description = "Prevent accounts from leaving the organization"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyLeaveOrganization"
Effect = "Deny"
Action = "organizations:LeaveOrganization"
Resource = "*"
}
]
})
}
resource "aws_organizations_policy_attachment" "deny_leave_org_root" {
policy_id = aws_organizations_policy.deny_leave_org.id
target_id = var.organization_root_id
}
# Region restriction (attach to root)
resource "aws_organizations_policy" "deny_unapproved_regions" {
name = "deny-unapproved-regions"
description = "Deny actions outside approved regions"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnapprovedRegions"
Effect = "Deny"
NotAction = [
"a4b:*",
"access-analyzer:*",
"account:*",
"acm:*",
"aws-portal:*",
"budgets:*",
"ce:*",
"chime:*",
"cloudfront:*",
"config:*",
"cur:*",
"globalaccelerator:*",
"health:*",
"iam:*",
"importexport:*",
"mobileanalytics:*",
"organizations:*",
"pricing:*",
"route53:*",
"route53domains:*",
"s3:GetBucketLocation",
"s3:ListAllMyBuckets",
"shield:*",
"sts:*",
"support:*",
"trustedadvisor:*",
"waf:*",
"wafv2:*",
"wellarchitected:*"
]
Resource = "*"
Condition = {
StringNotEquals = {
"aws:RequestedRegion" = var.approved_regions
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "deny_unapproved_regions_root" {
policy_id = aws_organizations_policy.deny_unapproved_regions.id
target_id = var.organization_root_id
}
# Security guardrails (attach to production OU)
resource "aws_organizations_policy" "production_guardrails" {
name = "production-guardrails"
description = "Additional guardrails for production accounts"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ProtectSecurityServices"
Effect = "Deny"
Action = [
"guardduty:DeleteDetector",
"guardduty:DisassociateFromMasterAccount",
"securityhub:DisableSecurityHub",
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"config:DeleteConfigRule",
"config:DeleteConfigurationRecorder",
"config:StopConfigurationRecorder"
]
Resource = "*"
},
{
Sid = "RequireIMDSv2"
Effect = "Deny"
Action = "ec2:RunInstances"
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
StringNotEquals = {
"ec2:MetadataHttpTokens" = "required"
}
}
},
{
Sid = "DenyPublicS3"
Effect = "Deny"
Action = "s3:PutBucketPublicAccessBlock"
Resource = "*"
Condition = {
"ForAnyValue:StringEquals" = {
"s3:x-amz-acl" = ["public-read", "public-read-write"]
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "production_guardrails" {
policy_id = aws_organizations_policy.production_guardrails.id
target_id = var.production_ou_id
}
# Sandbox restrictions (attach to sandbox OU)
resource "aws_organizations_policy" "sandbox_restrictions" {
name = "sandbox-restrictions"
description = "Cost and resource restrictions for sandbox accounts"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyExpensiveInstances"
Effect = "Deny"
Action = "ec2:RunInstances"
Resource = "arn:aws:ec2:*:*:instance/*"
Condition = {
"ForAnyValue:StringLike" = {
"ec2:InstanceType" = [
"*.metal",
"*.24xlarge",
"*.16xlarge",
"*.12xlarge",
"p*.*",
"inf*.*"
]
}
}
},
{
Sid = "DenyExpensiveServices"
Effect = "Deny"
Action = [
"redshift:*",
"emr:*",
"sagemaker:CreateNotebookInstance",
"sagemaker:CreateTrainingJob"
]
Resource = "*"
}
]
})
}
resource "aws_organizations_policy_attachment" "sandbox_restrictions" {
policy_id = aws_organizations_policy.sandbox_restrictions.id
target_id = var.sandbox_ou_id
}
Troubleshooting SCPs
Action Denied but Can’t Find Why
Use the IAM Policy Simulator or check CloudTrail:
# Check CloudTrail for the denial
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=RunInstances \
--query 'Events[?contains(CloudTrailEvent, `AccessDenied`)].CloudTrailEvent' \
--output text | jq .
The error message usually indicates if an SCP caused the denial:
"errorMessage": "User: arn:aws:iam::123456789012:user/dev is not authorized
to perform: ec2:RunInstances on resource: * with an explicit deny in a
service control policy"
Global Services Blocked
If global services (IAM, Route53, Organizations) fail, ensure you’re using NotAction or including us-east-1:
{
"Sid": "DenyRegionsExceptGlobal",
"Effect": "Deny",
"NotAction": [
"iam:*",
"organizations:*",
"route53:*",
"route53domains:*",
"cloudfront:*",
"globalaccelerator:*",
"support:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["eu-west-1", "eu-west-2", "us-east-1"]
}
}
}
Service-Linked Roles Failing
SCPs don’t apply to service-linked roles. If a service is failing because it can’t assume its service-linked role, the issue is elsewhere (IAM permissions, not SCPs).
Testing SCPs
Always test in a sandbox OU first:
# Create a test OU
resource "aws_organizations_organizational_unit" "scp_testing" {
name = "scp-testing"
parent_id = var.organization_root_id
}
# Attach the new SCP to test OU only
resource "aws_organizations_policy_attachment" "test_new_scp" {
policy_id = aws_organizations_policy.new_scp.id
target_id = aws_organizations_organizational_unit.scp_testing.id
}
# Move a test account to this OU
# (do this manually or via separate resource)
Best Practices
1. Start with Deny-List, Then Tighten
Begin with FullAWSAccess + deny policies. Once you understand usage patterns, consider allow-list for high-security environments.
2. Use Condition Keys
Don’t just deny actions - use conditions to make policies more precise:
{
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/AdminRole",
"arn:aws:iam::*:role/EmergencyAccess"
]
}
}
}
3. Tag-Based Exceptions
Use tags to create exceptions:
{
"Condition": {
"StringNotEquals": {
"aws:ResourceTag/SCP-Exempt": "true"
}
}
}
4. Document Everything
SCPs are organisation-wide. Document:
- What each SCP does
- Why it exists
- Who approved it
- When to review it
5. Have an Emergency Break-Glass
Create a role that’s exempt from restrictive SCPs:
{
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/EmergencyBreakGlass"
}
}
}
6. Monitor SCP Denials
Set up CloudWatch alerts for SCP-related denials:
resource "aws_cloudwatch_log_metric_filter" "scp_denials" {
name = "scp-denials"
pattern = "{ $.errorCode = \"AccessDenied\" && $.errorMessage = \"*service control policy*\" }"
log_group_name = aws_cloudwatch_log_group.cloudtrail.name
metric_transformation {
name = "SCPDenials"
namespace = "Security/SCPs"
value = "1"
}
}
resource "aws_cloudwatch_metric_alarm" "scp_denials" {
alarm_name = "scp-denial-spike"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "SCPDenials"
namespace = "Security/SCPs"
period = 300
statistic = "Sum"
threshold = 10
alarm_description = "Alert when SCP denials spike"
alarm_actions = [aws_sns_topic.security_alerts.arn]
}
SCP Limits
Be aware of these limits:
- Maximum SCPs per organisation: 1,000
- Maximum SCPs attached to a single entity: 5
- Maximum SCP size: 5,120 bytes
- Maximum nesting depth: 5 levels of OUs
If you hit the size limit, split into multiple SCPs or use wildcards.
Conclusion
SCPs are the most powerful preventive control in AWS. They enforce boundaries that even admin users can’t bypass. Start with region restrictions and security service protection, then expand based on your organisation’s needs.
Remember: SCPs don’t grant permissions - they limit them. Always combine with proper IAM policies for a defence-in-depth approach.