I got an AWS question and implemented it to make sure that the option is correct.
You have 30 AWS accounts under Organizations. A developer in one of them creates an S3 bucket, skips encryption, and now you have a compliance gap. By the time your next audit runs, there are three more buckets in the same state.
This post covers how to close that gap permanently: stopping unencrypted buckets before they are created, and fixing the ones that already exist.
Prerequisites
Check these before running terraform apply:
AWS Organizations
- Organizations are enabled, and SCPs are active. Go to the management account → AWS Organizations → Policies → Service Control Policies and confirm it shows "Enabled". If SCPs are not enabled, the policy in this post will be created but will have no effect.
Terraform executor permissions
The IAM principal running Terraform needs, at minimum:
organizations:CreatePolicy
organizations:AttachPolicy
organizations:DescribePolicy
iam:CreateRole
iam:AttachRolePolicy
iam:PassRole
config:PutConfigRule
config:PutRemediationConfigurations
config:PutConfigurationRecorder
config:PutDeliveryChannel
config:StartConfigurationRecorder
cloudformation:CreateStackSet
cloudformation:CreateStackInstances
cloudformation:DescribeStackSet
AdministratorAccess on the management account covers all of these. Lock it down after the initial setup.
Config delivery bucket
AWS Config needs an S3 bucket to store configuration snapshots and history. The bucket must exist before you run terraform apply, and it needs a specific bucket policy that allows Config to write to it.
# config_bucket.tf - create this before the rest
resource "aws_s3_bucket" "config_delivery" {
bucket = "config-delivery-${data.aws_caller_identity.current.account_id}"
force_destroy = false
}
resource "aws_s3_bucket_policy" "config_delivery" {
bucket = aws_s3_bucket.config_delivery.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSConfigBucketPermissionsCheck"
Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.config_delivery.arn
},
{
Sid = "AWSConfigBucketDelivery"
Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.config_delivery.arn}/AWSLogs/*/Config/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
}
}
}
]
})
}
data "aws_caller_identity" "current" {}
Without this exact bucket policy, Config will fail to start the recorder, and you will see InsufficientDeliveryPolicyException errors.
How does the solution work?
SCP: the preventive layer
A Service Control Policy attached to the root OU acts as a guardrail across every account in the organization. It does not grant permissions. It restricts what IAM can allow. Even an account admin cannot override it.
The policy denies s3:CreateBucket unless the request includes the SSE-KMS header. The bucket never gets created if encryption is not specified.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyS3CreateBucketWithoutSSEKMS",
"Effect": "Deny",
"Action": "s3:CreateBucket",
"Resource": "*",
"Condition": {
"StringNotEqualsIfExists": {
"s3:x-amz-server-side-encryption": "aws:kms"
},
"Null": {
"s3:x-amz-server-side-encryption": "true"
}
}
}
]
}
The StringNotEqualsIfExists + Null combination covers two cases: requests that include the header with the wrong value, and requests that omit the header entirely. Using only StringNotEquals would miss the second case.
AWS Config: the detective and corrective layer
For buckets that already exist, Config evaluates each one against the s3-bucket-server-side-encryption-enabled managed rule. When it finds a non-compliant bucket, it triggers an SSM Automation document that applies SSE-KMS automatically.
No manual intervention. No Lambda to maintain.
Terraform
The files below create the SCP, the delivery bucket for Config, the Config rule with remediation for the management account, and deploy the same Config rule to all member accounts using a StackSet.
File structure
├── main.tf
├── variables.tf
├── config_bucket.tf
├── scp.tf
├── config.tf
├── iam.tf
└── stackset.tf
variables.tf
variable "organization_root_id" {
description = "Root ID of the AWS Organization (e.g. r-xxxx)"
type = string
}
variable "kms_key_arn" {
description = "ARN of the KMS key to use for SSE-KMS enforcement"
type = string
}
scp.tf: create and attach the SCP
resource "aws_organizations_policy" "s3_require_ssekms" {
name = "RequireS3SSEKMS"
description = "Denies creation of S3 buckets without SSE-KMS"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyS3CreateBucketWithoutSSEKMS"
Effect = "Deny"
Action = "s3:CreateBucket"
Resource = "*"
Condition = {
StringNotEqualsIfExists = {
"s3:x-amz-server-side-encryption" = "aws:kms"
}
Null = {
"s3:x-amz-server-side-encryption" = "true"
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "s3_ssekms_root" {
policy_id = aws_organizations_policy.s3_require_ssekms.id
target_id = var.organization_root_id
}
Attaching to the root OU means every account in the organization is covered, including accounts added in the future.
iam.tf: roles for Config and SSM remediation
Two roles are needed. The first allows Config to record and deliver configuration data. The second allows SSM Automation to call s3:PutEncryptionConfiguration during remediation. Without this second role, auto-remediation will trigger but fail silently. Config marks the execution as failed and retries up to the maximum_automatic_attempts limit.
# Role for AWS Config recorder
resource "aws_iam_role" "config_role" {
name = "AWSConfigRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "config_managed" {
role = aws_iam_role.config_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
}
# Role for SSM Automation remediation
resource "aws_iam_role" "ssm_remediation_role" {
name = "SSMRemediationS3SSEKMSRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ssm.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "ssm_remediation_policy" {
name = "SSMRemediationS3SSEKMSPolicy"
role = aws_iam_role.ssm_remediation_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowS3Encryption"
Effect = "Allow"
Action = [
"s3:PutEncryptionConfiguration",
"s3:GetEncryptionConfiguration"
]
Resource = "*"
},
{
Sid = "AllowKMSDescribe"
Effect = "Allow"
Action = [
"kms:DescribeKey"
]
Resource = var.kms_key_arn
}
]
})
}
config.tf - Config rule and remediation for the management account
If you also want to enforce this on the management account itself, add the rule directly. Member accounts are handled via the StackSet below.
resource "aws_config_config_rule" "s3_sse_enabled" {
name = "s3-bucket-server-side-encryption-enabled"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED"
}
depends_on = [aws_config_configuration_recorder_status.main]
}
resource "aws_config_remediation_configuration" "s3_sse_remediation" {
config_rule_name = aws_config_config_rule.s3_sse_enabled.name
target_type = "SSM_DOCUMENT"
target_id = "AWS-EnableS3BucketEncryption"
automatic = true
maximum_automatic_attempts = 3
retry_attempt_seconds = 60
# The role ARN is required - without it SSM has no permissions to act
resource_type = "AWS::S3::Bucket"
parameter {
name = "AutomationAssumeRole"
static_value = aws_iam_role.ssm_remediation_role.arn
}
parameter {
name = "BucketName"
resource_value = "RESOURCE_ID"
}
parameter {
name = "SSEAlgorithm"
static_value = "aws:kms"
}
parameter {
name = "KMSMasterKeyID"
static_value = var.kms_key_arn
}
execution_controls {
ssm_controls {
concurrent_execution_rate_percentage = 25
error_percentage = 20
}
}
}
resource "aws_config_configuration_recorder" "main" {
name = "default"
role_arn = aws_iam_role.config_role.arn
recording_group {
all_supported = true
}
}
resource "aws_config_delivery_channel" "main" {
name = "default"
s3_bucket_name = aws_s3_bucket.config_delivery.bucket
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_configuration_recorder_status" "main" {
name = aws_config_configuration_recorder.main.name
is_enabled = true
depends_on = [aws_config_delivery_channel.main]
}
stackset.tf: deploy Config rule to all member accounts
CloudFormation StackSets let you deploy the Config rule and remediation to every member account without logging into each one. The AutomationAssumeRole in the template body references a role that must exist in each member account. The StackSet creates it via CAPABILITY_NAMED_IAM.
resource "aws_cloudformation_stack_set" "config_s3_ssekms" {
name = "ConfigS3SSEKMSRule"
permission_model = "SERVICE_MANAGED"
description = "Deploys AWS Config rule to enforce SSE-KMS on S3 buckets"
auto_deployment {
enabled = true
retain_stacks_on_account_removal = false
}
capabilities = ["CAPABILITY_NAMED_IAM"]
template_body = jsonencode({
AWSTemplateFormatVersion = "2010-09-09"
Parameters = {
KMSKeyArn = {
Type = "String"
Description = "ARN of the KMS key for SSE-KMS"
}
}
Resources = {
SSMRemediationRole = {
Type = "AWS::IAM::Role"
Properties = {
RoleName = "SSMRemediationS3SSEKMSRole"
AssumeRolePolicyDocument = {
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ssm.amazonaws.com" }
Action = "sts:AssumeRole"
}]
}
Policies = [{
PolicyName = "SSMRemediationS3SSEKMSPolicy"
PolicyDocument = {
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:PutEncryptionConfiguration", "s3:GetEncryptionConfiguration"]
Resource = "*"
},
{
Effect = "Allow"
Action = ["kms:DescribeKey"]
Resource = { Ref = "KMSKeyArn" }
}
]
}
}]
}
}
S3SSEKMSConfigRule = {
Type = "AWS::Config::ConfigRule"
Properties = {
ConfigRuleName = "s3-bucket-server-side-encryption-enabled"
Source = {
Owner = "AWS"
SourceIdentifier = "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED"
}
}
}
S3SSEKMSRemediation = {
Type = "AWS::Config::RemediationConfiguration"
DependsOn = ["S3SSEKMSConfigRule"]
Properties = {
ConfigRuleName = "s3-bucket-server-side-encryption-enabled"
TargetType = "SSM_DOCUMENT"
TargetId = "AWS-EnableS3BucketEncryption"
Automatic = true
MaximumAutomaticAttempts = 3
RetryAttemptSeconds = 60
ResourceType = "AWS::S3::Bucket"
Parameters = {
AutomationAssumeRole = {
StaticValue = {
Values = [{
"Fn::GetAtt" = ["SSMRemediationRole", "Arn"]
}]
}
}
BucketName = { ResourceValue = { Value = "RESOURCE_ID" } }
SSEAlgorithm = { StaticValue = { Values = ["aws:kms"] } }
KMSMasterKeyID = { StaticValue = { Values = [{ Ref = "KMSKeyArn" }] } }
}
}
}
}
})
parameters {
parameter_key = "KMSKeyArn"
parameter_value = var.kms_key_arn
}
}
resource "aws_cloudformation_stack_set_instance" "config_s3_ssekms_org" {
stack_set_name = aws_cloudformation_stack_set.config_s3_ssekms.name
deployment_targets {
organizational_unit_ids = [var.organization_root_id]
}
operation_preferences {
failure_tolerance_percentage = 20
max_concurrent_percentage = 25
}
}
auto_deployment enabled means that any new account added to the organization gets the Config rule and SSM role deployed automatically.
What happens after deployment?
New bucket without SSE-KMS -> s3:CreateBucket hits the SCP condition -> AccessDenied. The bucket is never created.
New bucket with SSE-KMS -> passes the SCP condition -> created normally.
Existing bucket without SSE-KMS -> Config evaluates it as non-compliant -> SSM Automation triggers using SSMRemediationS3SSEKMSRole -> AWS-EnableS3BucketEncryption calls s3:PutEncryptionConfiguration -> bucket becomes compliant. This happens within minutes of the rule being active.
Validating the deployment
Three checks to run after terraform apply completes.
Check 1: SCP is blocking unencrypted bucket creation
From any member account (not the management account, which is excluded from SCPs):
# This should fail with AccessDenied
aws s3api create-bucket \
--bucket test-no-encryption-$(date +%s) \
--region us-east-1
# Expected output:
# An error occurred (AccessDenied) when calling the CreateBucket operation
# This should succeed
aws s3api create-bucket \
--bucket test-with-encryption-$(date +%s) \
--region us-east-1 \
--create-bucket-configuration LocationConstraint=us-east-1 \
--object-lock-enabled-for-bucket
aws s3api put-bucket-encryption \
--bucket test-with-encryption-<timestamp> \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "<your-kms-key-arn>"
}
}]
}'
Check 2: Config rule is active and evaluating
aws configservice describe-config-rules \
--config-rule-names s3-bucket-server-side-encryption-enabled \
--query 'ConfigRules[0].ConfigRuleState'
# Expected: "ACTIVE"
Check 3: compliance status for existing buckets
aws configservice get-compliance-details-by-config-rule \
--config-rule-name s3-bucket-server-side-encryption-enabled \
--compliance-types NON_COMPLIANT \
--query 'EvaluationResults[*].EvaluationResultIdentifier.EvaluationResultQualifier.ResourceId'
Non-compliant buckets appearing here mean the SSM Automation has not run yet. Wait a few minutes and run the query again. To trigger remediation immediately:
aws configservice start-remediation-execution \
--config-rule-name s3-bucket-server-side-encryption-enabled \
--resource-keys '[{"resourceType":"AWS::S3::Bucket","resourceId":"<bucket-name>"}]'
A note on the KMS key
The KMSMasterKeyID parameter accepts either a key ARN or an alias. In a multi-account setup, you have two options:
- Use a single centralized KMS key in the management account and share it via key policy with member accounts
- Create one KMS key per account and pass the correct ARN per account in the StackSet parameter overrides
A centralized key with cross-account access is simpler to manage. If data sovereignty or account isolation is a concern, per-account keys give you finer control.
Cost considerations
AWS Config charges per configuration item recorded. Every time a resource changes state, Config records it. With many accounts and many buckets, the total is worth knowing before you deploy.
Current pricing:
- Config rule evaluations: $0.001 per evaluation
- Configuration items recorded: $0.003 per item in the first 100K per month, per region
Example: 50 accounts × 20 buckets × 10 configuration changes per bucket per month = 10,000 configuration items, around $30/month across the organization. The number grows with bucket churn, so if your environment creates and deletes buckets frequently, pull a Config cost report after the first month.
The SCP itself has no cost.
Why not the other options?
Option 1 (CloudFormation StackSets + Config only, no SCP) covers existing buckets but does nothing to prevent new ones from being created without encryption. You are always reacting.
Option 2 (Security Hub + EventBridge + Lambda) works, but you are now maintaining Lambda code, IAM roles for that Lambda, and EventBridge rules per account. More moving parts, more things that can break silently.
Option 3 (bucket policies via StackSets + Drift Detection) requires that the bucket already exists before you can apply a policy to it. It does not prevent the creation of unencrypted buckets. It adds a policy after the fact. Drift Detection is manual.
The SCP + Config combination uses two managed AWS mechanisms, no custom code, and applies automatically to new accounts.
See you next time, right? Bye.
Top comments (0)