Skip to main content
emnode / learn
Compliance

Encrypt S3 object storage at rest

One capability across S3 buckets and the CodeBuild logs that land in them: every object is already encrypted, so the work is upgrading sensitive data from an opaque default key to a KMS key you can govern and audit.

12 min·10 sections·AWS

Last reviewed

Remediates AWS Security Hub: CodeBuild.3S3.17

Encrypting object storage at rest: the basics

Why 'already encrypted' is not the same as 'controlled'

Every Amazon S3 general purpose bucket encrypts objects at rest by default; there is no such thing as an unencrypted S3 object anymore. So this capability is not about adding encryption where there was none, it is about which key does the encrypting and whether you can govern it. Security Hub S3.17 checks whether a bucket uses an AWS KMS key (SSE-KMS, or its dual-layer variant DSSE-KMS) as its default encryption, and fails when the bucket falls back to the Amazon-owned default (SSE-S3). CodeBuild.3 is the same idea one step upstream: build logs streamed to S3 must not have encryption explicitly disabled via the project's encryptionDisabled flag.

They look like two separate findings, but they are one capability: prove that the objects holding sensitive data, including the build logs that quietly capture source paths, tokens and connection strings, sit behind a key you control with an audit trail, rather than behind an Amazon-managed key you cannot see, log or revoke. With SSE-S3 there is no key policy to show an auditor and no CloudTrail record of who decrypted what. With SSE-KMS, decryption requires kms:Decrypt on the key, every use is logged, and you can revoke access by editing the key policy without touching the bucket or its objects.

This capability is gentler than the database and block-storage ones because nothing is immutable: flipping a bucket's default encryption to KMS, or re-enabling encryption on a CodeBuild project, is a metadata change, not a re-encryption project. The control passes the moment the configuration changes. The two things that actually take thought are the KMS key policy (get it wrong and reads fail with AccessDenied at the KMS layer even when S3 permissions are intact) and KMS request cost on high-traffic buckets, which one feature, S3 Bucket Keys, keeps flat.

In this lesson you will learn the difference between SSE-S3 and SSE-KMS, why these controls treat the default key as a governance gap rather than an exposure, how to flip a bucket's default encryption to a KMS key (and re-enable encryption on a CodeBuild project) without re-writing a single object, and the one feature, S3 Bucket Keys, that keeps the KMS request bill flat on high-traffic buckets. The Controls this lesson covers section lists every Security Hub control in this capability, each linking to a deep page with the exact check and a copy-and-paste fix.

Fun fact

The 0.001 percent that paid for itself in a week

When AWS launched S3 Bucket Keys in 2020, the pitch was almost too good: enable one setting and your KMS API request charges for a bucket drop by up to ninety-nine percent. The mechanism is simple. Instead of calling KMS once per object, S3 uses a short-lived bucket-level data key to encrypt many objects, collapsing thousands of GenerateDataKey calls into one. One analytics team that had quietly racked up a five-figure monthly KMS bill from a bucket serving billions of small-object reads flipped Bucket Keys on and watched the charge fall to under a hundred dollars the following month, without changing a single line of application code or re-encrypting anything. It is the rare cost lever that costs nothing and weakens nothing: the security boundary is identical, only the call volume changes.

Finding object storage that is on the default key

Diego owns the compliance backlog. Security Hub flags customer-records-prod as failing S3.17 (it is on the default SSE-S3 key and holds PII in PCI scope) alongside two CodeBuild projects whose build logs were configured with encryption explicitly disabled by an old Terraform module.

Rather than flip everything blindly, he starts by confirming the actual encryption configuration of the bucket, because the fix is a metadata change and the real work is mapping who reads it before he changes the key.

Check a bucket's default encryption. An SSEAlgorithm of AES256 is the SSE-S3 signature that fails S3.17; aws:kms is what the control wants to see.

$ aws s3api get-bucket-encryption --bucket customer-records-prod --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault'
{
"SSEAlgorithm": "AES256"
}
# AES256 == SSE-S3 (Amazon-managed key): encrypted, but no key policy and no audit trail.

The data is encrypted either way. S3.17 is asking whether the key is one you can govern, not whether the bytes are scrambled.

How S3 object encryption worksdeep dive

S3 server-side encryption always uses envelope encryption: each object is encrypted with a unique data key, and that data key is itself encrypted with a root key. The difference between SSE-S3 and SSE-KMS is who owns the root key and whether its use is authorised and logged. With SSE-S3 (SSEAlgorithm: AES256) the root key is an Amazon-owned key: invisible, free and uncontrollable. With SSE-KMS (SSEAlgorithm: aws:kms) the root key is a KMS key you govern with a key policy, and every GenerateDataKey and Decrypt call lands in CloudTrail. DSSE-KMS applies two independent layers of KMS encryption for the rare workloads that need it. CodeBuild logs ride on the same model: when encryptionDisabled is false the log object is written with server-side encryption (SSE-S3 by default, or SSE-KMS if the bucket targets a customer-managed key), and the control passes either way; setting encryptionDisabled to true tells CodeBuild to write plaintext regardless of the bucket's default.

Setting default encryption is a metadata change on the bucket, not a re-encryption job. The controls evaluate the configuration, so the finding flips to PASSED the moment the default is aws:kms (or the CodeBuild flag is false). But objects written before the change keep whatever key encrypted them: the default only governs new writes. To migrate existing objects you copy them in place, which S3 Batch Operations automates for large buckets. CodeBuild.3 is backed by the change-triggered Config rule codebuild-project-s3-logs-encrypted, so an update-project call clears it within minutes.

Two things take real thought. First, the key policy: with SSE-KMS, reading an object requires both S3 permission and kms:Decrypt on the key (S3 calls KMS on the caller's behalf), so every Lambda, cross-account role and replication principal that reads the bucket must be on the key policy, or reads fail with AccessDenied at the KMS layer even though S3 permissions are intact. Map consumers first, flip second. Second, request cost: without S3 Bucket Keys, SSE-KMS calls KMS once per object operation, which on billions of small-object reads is both a cost problem and a throttling risk; Bucket Keys collapse those calls by up to ninety-nine percent with a single boolean and no change to the security boundary.

What is the impact of leaving object storage on the default key?

The first impact is compliance evidence. NIST 800-53 SC-28 and SC-13 and PCI DSS do not just want data encrypted, they want a controllable, auditable key boundary around sensitive data at rest. SSE-S3 cannot provide one: there is no key policy to show an auditor and no CloudTrail record of decryption. A bucket on SSE-S3 holding regulated data, or a build log written in plaintext, is a finding that stays open on every report until it is moved behind a KMS key, regardless of the fact the bytes are encrypted.

The second impact is access control and blast radius. With SSE-KMS, the second gate (kms:Decrypt) lets you revoke access to data by editing a key policy: useful for incident response, offboarding a partner account, or fencing off a dataset, without touching the bucket. In a breach, the CloudTrail key-usage log answers who accessed sensitive data and when, so the disclosure scope is a fact rather than an assumption. SSE-S3 has neither gate nor log, so the blast radius of an exposure is undefined. Build logs sharpen this: they routinely echo source paths, dependency URLs and, when a debug step prints too much, fragments of tokens and credentials.

The cost impact cuts both ways and is the one place this remediation can backfire if done carelessly. KMS adds a flat dollar a month per key plus three cents per ten thousand requests, which for most buckets is negligible; but on a high-traffic bucket, per-request KMS charges without Bucket Keys can climb into four or five figures a month and can hit KMS request throttling limits. The fix is always the same: enable S3 Bucket Keys. The operational risk is the key policy: flip a shared bucket to a key a downstream consumer cannot use, and reads start failing at the KMS layer. The remediation is low-risk only when you map every consumer to the key policy before flipping the switch.

How do you encrypt object storage safely?

Work the capability as one loop. The order matters: inventory by encryption type and data sensitivity, design the key and its policy around who actually reads each bucket, flip the configuration with Bucket Keys enabled, then migrate existing objects and enforce the standard going forward.

1. Inventory by encryption type and data sensitivity

Audit every bucket's default encryption and every CodeBuild project's s3Logs config, splitting them into three groups: already on aws:kms (compliant), on the default key while holding regulated or sensitive data (urgent), and on the default while holding non-sensitive data (policy-driven, lower priority). Sensitivity classification matters more than the finding count: a customer-managed key is mandatory for regulated data and merely good practice elsewhere. This classification is the work that actually closes the gap; the config change is the easy part.

2. Design the key and its policy around the bucket's consumers

Before flipping anything, list every principal that reads or writes the bucket: applications, Lambda execution roles, cross-account analytics roles, replication. Group buckets by data domain onto a small number of customer-managed keys rather than one key per bucket. Write a key policy granting kms:Decrypt and kms:GenerateDataKey to exactly those principals, and no more. Getting this wrong causes AccessDenied at the KMS layer even when S3 permissions are intact, so map consumers first, flip second.

3. Flip the configuration with S3 Bucket Keys enabled

Set the bucket's default encryption to the customer-managed key with BucketKeyEnabled true, and for CodeBuild call update-project to set encryptionDisabled false on the s3Logs block. The Bucket Keys flag is non-negotiable on any high-traffic bucket: it collapses per-object KMS calls by up to ninety-nine percent and protects you from both runaway request charges and KMS throttling. The findings flip to PASSED as soon as the configuration changes, because the controls evaluate configuration, not existing objects.

4. Migrate existing objects and enforce the standard forward

Default encryption only governs new writes, so existing objects keep their old key until rewritten. Migrate them with a copy-in-place (S3 Batch Operations for large buckets) so the whole dataset sits behind the controlled key. Then prevent regression: a bucket policy that denies s3:PutObject unless the request specifies aws:kms, corrected Terraform module defaults so new CodeBuild projects encrypt, and the Config rules (codebuild-project-s3-logs-encrypted) that catch any drift automatically.

# 1. Audit every bucket and flag the ones still on the default key (SSE-S3).
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
  alg=$(aws s3api get-bucket-encryption --bucket "$b" \
    --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm' \
    --output text 2>/dev/null)
  [ "$alg" != "aws:kms" ] && echo "FAIL S3.17: $b (current: ${alg:-none})"
done

# 2. Flip a bucket to a customer-managed KMS key WITH Bucket Keys (cost lever) enabled.
KEY=arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab
aws s3api put-bucket-encryption --bucket customer-records-prod \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"'$KEY'"},"BucketKeyEnabled":true}]}'

# 3. Re-enable encryption on a CodeBuild project's S3 logs (CodeBuild.3).
aws codebuild update-project --name api-service-build \
  --logs-config '{"s3Logs":{"status":"ENABLED","location":"my-build-logs-bucket/api-service","encryptionDisabled":false}}'

# 4. Migrate existing objects in place so the whole dataset sits behind the controlled key.
aws s3 cp s3://customer-records-prod/ s3://customer-records-prod/ \
  --recursive --sse aws:kms --sse-kms-key-id $KEY

Quick quiz

Question 1 of 5

A bucket holding PII fails S3.17 with SSEAlgorithm: AES256 and serves billions of small-object reads per month. What is the right remediation?

You can now treat object storage encryption as one capability rather than two unrelated findings: the data is already encrypted, so the work is moving sensitive buckets and build logs behind a KMS key you control with an audit trail. Inventory by sensitivity, map every consumer to the key policy before you flip, enable S3 Bucket Keys so the request bill stays flat, migrate existing objects, and lock the standard with a deny-policy and Config rules. The Controls this lesson covers section below links every control in this group to its deep page and fix.

Back to the library

Controls this lesson covers

One capability, many AWS Security Hub controls. This lesson is the shared playbook; each control below keeps its own deep page with the exact check, severity and a copy-and-paste fix.