DEV Community

Enforcing SSE-KMS on S3 buckets across all AWS accounts

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
Enter fullscreen mode Exit fullscreen mode

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" {}
Enter fullscreen mode Exit fullscreen mode

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"
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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>"
      }
    }]
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>"}]'
Enter fullscreen mode Exit fullscreen mode

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)