Skip to main content

Service Control Policies (Preventive Controls)

TSC mapping: CC5.2 (Control activities โ€” preventive), CC6.1, CC6.6, CC7.1

Every other SOC 2 guide focuses on detective controls โ€” Config rules detect a violation, GuardDuty fires an alert, your on-call investigates. That approach has a reaction window.

Service Control Policies eliminate the reaction window. An SCP is an IAM permission boundary applied at the AWS Organizations level. When an SCP denies an action, the API call fails โ€” it doesn't matter what the IAM policy says. The violation never happens, so there is no finding, no alert, and no remediation required.

Detective control: "We noticed someone disabled CloudTrail. We've re-enabled it and opened a ticket."

Preventive control: "CloudTrail cannot be disabled. The API call returns AccessDenied."

Both approaches produce SOC 2 evidence. The preventive approach produces better evidence โ€” you can show the auditor that the control is structurally enforced, not dependent on a human responding to an alert within an SLA.


How SCPs Workโ€‹

AWS Organization Root
โ””โ”€โ”€ Organizational Unit (OU): Production
โ”œโ”€โ”€ Account: prod-main โ† SCPs apply here
โ”œโ”€โ”€ Account: prod-logs โ† SCPs apply here
โ””โ”€โ”€ Account: prod-security โ† SCPs apply here (with SCP exceptions as needed)
  • SCPs are allowlists or denylists attached to OUs or individual accounts.
  • The management (root) account is never restricted by SCPs โ€” keep it empty except for automation.
  • SCPs interact with IAM policies as an AND: both must permit an action for it to succeed.
  • SCPs do not grant permissions โ€” they only restrict what IAM policies can grant.

Attach an SCP:

# Get your OU ID
aws organizations list-organizational-units-for-parent \
--parent-id $(aws organizations list-roots --query 'Roots[0].Id' --output text) \
--query 'OrganizationalUnits[*].[Id,Name]' --output table

# Create the policy
aws organizations create-policy \
--name "DenyCloudTrailDisable" \
--type SERVICE_CONTROL_POLICY \
--description "Prevents CloudTrail from being stopped or deleted" \
--content file://scp-deny-cloudtrail-disable.json

# Attach to an OU
aws organizations attach-policy \
--policy-id p-xxxxxxxxxx \
--target-id ou-xxxx-xxxxxxxx

SOC 2 SCP Libraryโ€‹

1. Deny Disabling CloudTrailโ€‹

Without this, anyone with cloudtrail:StopLogging or cloudtrail:DeleteTrail can blind your audit trail. This is the single most critical SCP for SOC 2.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyCloudTrailDisable",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/BreakGlassRole",
"arn:aws:iam::*:role/AWSControlTowerExecution"
]
}
}
}
]
}
note

Always include a Condition exemption for your break-glass role and any automation roles that legitimately manage trails. Without it, you lock yourself out of updating your own trail configuration.


2. Deny Disabling GuardDuty and Security Hubโ€‹

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDisableGuardDuty",
"Effect": "Deny",
"Action": [
"guardduty:DeleteDetector",
"guardduty:DisassociateFromMasterAccount",
"guardduty:StopMonitoringMembers",
"guardduty:UpdateDetector"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityAdminRole"
}
}
},
{
"Sid": "DenyDisableSecurityHub",
"Effect": "Deny",
"Action": [
"securityhub:DisableSecurityHub",
"securityhub:DeleteMembers",
"securityhub:DisassociateMembers"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityAdminRole"
}
}
}
]
}

3. Deny Public S3 Bucketsโ€‹

Enforces at the API level โ€” blocks PutBucketAcl calls that would make a bucket public, regardless of who makes them.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyS3PublicAccess",
"Effect": "Deny",
"Action": [
"s3:PutBucketPublicAccessBlock",
"s3:PutAccountPublicAccessBlock"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"s3:PublicAccessBlockConfiguration/BlockPublicAcls": "false",
"s3:PublicAccessBlockConfiguration/IgnorePublicAcls": "false",
"s3:PublicAccessBlockConfiguration/BlockPublicPolicy": "false",
"s3:PublicAccessBlockConfiguration/RestrictPublicBuckets": "false"
}
}
},
{
"Sid": "DenyS3PublicACL",
"Effect": "Deny",
"Action": [
"s3:PutBucketAcl",
"s3:PutObjectAcl"
],
"Resource": "*",
"Condition": {
"StringEqualsIgnoreCase": {
"s3:x-amz-acl": [
"public-read",
"public-read-write",
"authenticated-read"
]
}
}
}
]
}

4. Require Encryption on S3 Uploadsโ€‹

Denies PutObject calls that don't include server-side encryption headers. No unencrypted data can land in S3.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnencryptedS3Puts",
"Effect": "Deny",
"Action": "s3:PutObject",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": [
"aws:kms",
"AES256"
]
}
}
}
]
}

5. Restrict to Approved AWS Regionsโ€‹

Prevents resources from being accidentally or maliciously created in unapproved regions โ€” a common compliance gap when engineers experiment.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyNonApprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*",
"organizations:*",
"route53:*",
"budgets:*",
"waf::*",
"cloudfront:*",
"sts:*",
"support:*",
"account:*",
"health:*",
"trustedadvisor:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"us-east-1",
"us-west-2",
"eu-west-1"
]
}
}
}
]
}
tip

Global services (IAM, Route53, CloudFront, etc.) must be in NotAction โ€” they don't operate in a region and will fail if you include them in the region restriction.


6. Deny Root Account Usageโ€‹

The root account appears in CloudTrail as userIdentity.type = Root. This SCP denies all root API activity except for the specific actions that can only be taken by root.

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

Attach this only to member accounts, never to the management account or the root OU. Root-only actions (closing accounts, restoring S3 object ownership) must remain available in the management account for break-glass scenarios.


7. Deny IAM User Creation (Enforce Identity Center)โ€‹

If your policy is "no long-lived IAM users โ€” use Identity Center," enforce it here.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyIAMUserCreation",
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:CreateAccessKey"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/TerraformRole",
"arn:aws:iam::*:role/BreakGlassRole"
]
}
}
}
]
}

8. Deny KMS Key Deletionโ€‹

Accidental or malicious KMS key deletion is catastrophic โ€” it permanently destroys access to all data encrypted with that key.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyKMSKeyDeletion",
"Effect": "Deny",
"Action": [
"kms:ScheduleKeyDeletion",
"kms:DeleteImportedKeyMaterial"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/KeyAdminRole"
}
}
}
]
}

9. Deny Disabling AWS Configโ€‹

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDisableConfig",
"Effect": "Deny",
"Action": [
"config:StopConfigurationRecorder",
"config:DeleteConfigurationRecorder",
"config:DeleteDeliveryChannel",
"config:DeleteRetentionConfiguration"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/SecurityAdminRole"
}
}
}
]
}

Testing SCPs Safelyโ€‹

Before attaching to production OUs, test using IAM policy simulator or a sandbox account:

# Simulate whether a specific principal can perform an action
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::<account>:role/DeveloperRole \
--action-names cloudtrail:StopLogging \
--resource-arns "*"

# After attaching an SCP, verify it blocks the action from a non-exempt principal
aws sts assume-role \
--role-arn arn:aws:iam::<account>:role/DeveloperRole \
--role-session-name scp-test

# Then attempt the denied action with the assumed credentials
AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... \
aws cloudtrail stop-logging --name org-trail
# Expected: An error occurred (AccessDenied)

Detective vs. Preventive Controls โ€” Comparisonโ€‹

ScenarioConfig Rule (detective)SCP (preventive)
Engineer disables CloudTrailAlert fires, ticket opened, re-enable within SLAAPI call denied, nothing to remediate
Root account used in prodCloudWatch alarm fires, investigation requiredAPI call denied before it executes
Public S3 bucket createds3-bucket-public-access-prohibited flags itPutBucketAcl call denied
Unencrypted EBS volume createdencrypted-volumes rule flags itStill detective โ€” pair with launch template enforcement
Resource created in unapproved regionNo native Config rule covers this cleanlySCP blocks the API call
IAM user created instead of SSOiam-user-no-policies-check is indirectiam:CreateUser denied outright

Recommended approach: Implement both. SCPs prevent violations; Config rules catch anything SCPs can't cover and provide the compliance timeline evidence auditors want to see.


SOC 2 Evidence for SCPsโ€‹

Evidence itemHow to collect
SCP list with contentaws organizations list-policies --filter SERVICE_CONTROL_POLICY
SCP attachment mapaws organizations list-targets-for-policy --policy-id <id>
Denied API call logsCloudTrail โ€” filter errorCode = AccessDenied with userAgent showing SCP block
SCP test resultsScreen recording or log output from sandbox account testing

Reference: AWS Organizations SCPs โ†’ ยท SCP examples โ†’