Skip to content
Back to blog AWS Service Control Policies (SCPs) - Guardrails for Your Organization

AWS Service Control Policies (SCPs) - Guardrails for Your Organization

AWSSecurity

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:

  1. Allow strategy: Every level must explicitly allow it
  2. 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

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.


References

Found this helpful?

Comments