The log bucket that finally takes KMS
S3 server access logs could only land in an AES256 bucket for years, and AES256 is exactly what Security Hub flags. The escape was never a bucket setting. It was a KMS key policy that lets the logging service write with your key.
June 24, 2026
For a long time the central access-log bucket was the one bucket I could not encrypt the way every other bucket was encrypted. Every data bucket in the account used a customer-managed KMS key. The log bucket used AES256, because AES256 was the only thing that worked. And AES256 is exactly what the security baseline flags.
So the bucket sat there failing a control, with a documented exception next to it, because the alternative was a bucket that silently stopped receiving logs.
The bind
S3 server access logging delivers records through a service principal, logging.s3.amazonaws.com. That principal writes the log objects into the target bucket. For years the constraint on the target bucket was blunt: it had to use SSE-S3, the S3-managed AES256 keys, or no encryption at all. The S3 docs said it plainly, and some of them still do. Point server access logging at a bucket with SSE-KMS default encryption and the delivery breaks. Not with an error you can see. The logging service tries to encrypt a record with a key it has no permission to use, the write fails, and the logs just do not show up. You notice days later when you go looking for an access trail that was never written.
The other side of the bind is the security control. The AWS Foundational Security Best Practices control S3.17 checks that a bucket is encrypted with a KMS key. SSE-S3 fails it. The reasoning is sound: a KMS key gives you a key policy, an audit trail of every GenerateDataKey and Decrypt, and a second access gate independent of the bucket policy. SSE-S3 gives you none of that.
So the two requirements pointed in opposite directions:
Enable server access logging -> target bucket must be AES256 -> S3.17 failsSatisfy S3.17 -> target bucket must be SSE-KMS -> log delivery breaksYou could satisfy either one. Not both. The standard answer was to suppress the finding on the log bucket and move on.
What actually changed
The thing I had wrong for a long time is that I thought “SSE-KMS breaks log delivery” was a property of S3. It is not. It is a property of the key policy. Log delivery breaks because the logging service cannot use your key, and it cannot use your key because nothing told the key to let it.
S3 server access logging does support a customer-managed KMS key on the target bucket. The requirement is that the key’s policy grants the logging service principal the two operations it needs to encrypt a log object: kms:GenerateDataKey to mint the data key that encrypts the record, and kms:Decrypt for the envelope round-trip. Symmetric key only. Grant those two, scoped to your own account, and delivery works with your key, and the bucket passes S3.17 because it is now genuinely KMS-encrypted.
The whole fix lives in the key policy. The bucket configuration barely changes.
data "aws_iam_policy_document" "access_logs_kms" { statement { sid = "EnableRootAccountAdmin" effect = "Allow" actions = ["kms:*"] resources = ["*"] principals { type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } }
statement { sid = "AllowS3ServerAccessLogDelivery" effect = "Allow" actions = ["kms:GenerateDataKey", "kms:Decrypt"] resources = ["*"] principals { type = "Service" identifiers = ["logging.s3.amazonaws.com"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [data.aws_caller_identity.current.account_id] } }}
resource "aws_kms_key" "access_logs" { description = "KMS key for the central S3 server access-log bucket" enable_key_rotation = true policy = data.aws_iam_policy_document.access_logs_kms.json}Then the bucket points its default encryption at that key, and that is all the bucket has to know.
resource "aws_s3_bucket" "access_logs" { bucket = "s3-access-logs-${local.account_short_id}"}
resource "aws_s3_bucket_server_side_encryption_configuration" "access_logs" { bucket = aws_s3_bucket.access_logs.id rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" kms_master_key_id = aws_kms_key.access_logs.arn } bucket_key_enabled = true }}Why this works
Three lines in that policy carry the whole thing. It is worth being precise about each, because each one is load-bearing and the failure mode of getting any of them wrong is silence, not an error.
The principal is a service, not a role. The grantee is logging.s3.amazonaws.com. This is the identity S3 uses when it delivers access logs on your behalf — not your role, not the bucket. When the logging service goes to write a record into a KMS-encrypted bucket, KMS evaluates this statement against that principal. No statement for that principal means no permission means the encrypt fails means no log. The fix is not “allow KMS on the bucket.” It is “allow this specific service to use this specific key.”
GenerateDataKey is the operation that matters. SSE-KMS does not encrypt each object directly with your KMS key. It calls GenerateDataKey to get a fresh data key, encrypts the object with that, and stores the wrapped data key alongside. So the permission the logging service actually needs at write time is GenerateDataKey, not Encrypt. Grant Encrypt and miss GenerateDataKey and you have a policy that looks like it should work and does not. Decrypt covers the unwrap side of the same envelope. Those two are the complete set for this path; nothing else is needed and nothing less is enough.
The aws:SourceAccount condition closes the confused-deputy hole. A service principal grant is broad — logging.s3.amazonaws.com is the same principal in every AWS account on earth. Without a condition, you have written a key policy that lets any account’s S3 logging service ask your key to encrypt something. The aws:SourceAccount StringEquals condition pins it: only delivery originating in your own account may use the key. It is the standard cross-service guard, and on a key policy that names a shared service principal it is not optional.
bucket_key_enabled = true on the bucket is the quiet win. An S3 Bucket Key has S3 call GenerateDataKey once per key-rotation window instead of once per object. On a bucket that receives a log record per request, that is the difference between a sane KMS bill and an alarming one. It is also why granting the service the data-key operation, rather than per-object Encrypt, is the right shape.
One bucket, many sources
The reason this is worth doing once and well is that the log bucket is shared. Every data bucket in the account delivers its server access logs here, each under a prefix named for the source bucket. So the same KMS key encrypts logs arriving from many buckets, and the source side just names the central target.
On the source bucket, logging is two fields:
resource "aws_s3_bucket_logging" "this" { bucket = aws_s3_bucket.this.id target_bucket = var.access_logs_bucket_id target_prefix = "${aws_s3_bucket.this.id}/"}The source bucket does not need to know the target is KMS-encrypted. It does not reference the key. The entire encryption contract is held on the target side, between the bucket’s default-encryption setting and the key policy that lets the logging service honor it. Add a new data bucket next month, point its logging at the same target, and it just works, because the permission that makes it work was never about the source.
What the exception list looks like now
The change is small and the payoff is mostly bookkeeping, which is the honest version of it. No new finding to suppress, no exception to re-justify at the next audit, one less bucket that is encrypted differently from all the others for a reason nobody remembers. The log bucket is now a KMS-encrypted bucket like every other bucket, with an audit trail of who used its key, and the control passes because the bucket is actually doing the thing the control checks for.
The lesson I will keep: when a managed service “cannot” use your KMS key, read that as “has not been granted access to your KMS key” and go look at the key policy before you accept the weaker encryption. The constraint was never in S3. It was in a statement I had not written yet.
Addendum: does the same trick work for load balancer logs?
The code in this section is untested. I have run the S3 server-access-log policy above in anger; the load balancer policies below I have only reasoned about from the AWS docs. Treat them as a starting point, not a recipe.
The natural next thought is: my load balancers write access logs to S3 too, and those buckets have the same S3.17 problem. Does the same key-policy move free them? The answer splits by load balancer type, and the split is the whole point.
Application Load Balancer: no. ALB access log delivery only supports SSE-S3 on the target bucket. A customer-managed KMS key is not supported, full stop. It is worth being precise about why, because ALB does have a delivery identity and it is tempting to just add it to the key policy. In regions created after August 2022 that identity is the service principal logdelivery.elasticloadbalancing.amazonaws.com, and that is the setup I am assuming here. You grant that principal s3:PutObject in the bucket policy and delivery works. But that is a PutObject grant, not a KMS caller.
(Older regions, created before August 2022, use a different model — a per-region ELB-owned AWS account principal that the aws_elb_service_account data source resolves. I am not covering that here; if you are on a region that old, check which principal applies before copying any of this.) The ALB delivery path writes its objects with SSE-S3 and never issues a GenerateDataKey call against a customer-managed key — so adding the same principal to the key policy authorizes a call that is never made. The escape that works for S3 server access logs has an identity to hook to but no KMS call behind it. If the ALB log bucket has to land somewhere KMS-encrypted, that is a different design (deliver to an SSE-S3 bucket, then copy or re-encrypt downstream), not a one-line policy fix. For the bucket itself, the honest move is still the documented exception.
Network Load Balancer: yes, and it looks familiar. NLB access logs do support a customer-managed KMS key on the target bucket. The fix has the same shape as the S3 one — grant the delivery service the data-key operation — but two details differ, and they are the details that bite:
- The principal is
delivery.logs.amazonaws.com, notlogging.s3.amazonaws.com. Different service, different identity. - The action is
kms:GenerateDataKey*(the wildcard form), and that is the operation NLB delivery actually needs.
# UNTESTED — NLB access-log KMS key policy statement.statement { sid = "AllowNLBAccessLogDelivery" effect = "Allow" actions = ["kms:GenerateDataKey*"] resources = ["*"] principals { type = "Service" identifiers = ["delivery.logs.amazonaws.com"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [data.aws_caller_identity.current.account_id] }}And the rest: same shape, other services
Once you see it for NLB, you see it everywhere. A whole family of AWS log delivery routes through one service principal, delivery.logs.amazonaws.com, and grant that single principal the data-key operations on your key and all of them deliver into a CMK-encrypted bucket. Others bring their own principal but follow the identical shape. The principal and the exact action set are the only things that move:
| Service | Key-policy principal | Required KMS actions |
|---|---|---|
| S3 server access logs | logging.s3.amazonaws.com | kms:GenerateDataKey, kms:Decrypt |
| NLB access logs | delivery.logs.amazonaws.com | kms:GenerateDataKey* |
| VPC / Transit Gateway flow logs | delivery.logs.amazonaws.com | kms:Encrypt, kms:Decrypt, kms:ReEncrypt*, kms:GenerateDataKey*, kms:DescribeKey |
| Route 53 Resolver query logs | delivery.logs.amazonaws.com | kms:GenerateDataKey* |
| CloudTrail | cloudtrail.amazonaws.com | kms:GenerateDataKey*, kms:Decrypt, kms:DescribeKey |
| AWS Config | config.amazonaws.com | kms:GenerateDataKey*, kms:Decrypt |
| GuardDuty findings export | guardduty.amazonaws.com | kms:GenerateDataKey (CMK is required, not optional) |
| CloudFront standard logging (legacy) | uses the awslogsdelivery account | CMK supported, AWS-managed key is not |
The vended-logs family is the one worth writing once, because the same statement covers flow logs, NLB logs, and Resolver logs at the same time. Grant the fuller action set so it works for every member, scoped to your account:
# UNTESTED — covers the delivery.logs.amazonaws.com vended-logs family# (VPC/TGW flow logs, NLB access logs, Route 53 Resolver query logs).statement { sid = "AllowVendedLogDelivery" effect = "Allow" actions = [ "kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:DescribeKey", ] resources = ["*"] principals { type = "Service" identifiers = ["delivery.logs.amazonaws.com"] } condition { test = "StringEquals" variable = "aws:SourceAccount" values = [data.aws_caller_identity.current.account_id] }}Two details to copy carefully rather than assume. The action set differs by service — NLB asks for kms:GenerateDataKey* alone while flow logs want the full five above, so granting the wider set is the safe default for the shared key. And some of these, flow logs among them, insist on the key ARN and fail delivery if you hand them a key ID. Read the specific service’s key-policy page before writing the grant.
The holdouts are the short list, not the long one: ALB access logs and Classic Load Balancer access logs are the only services here stuck on SSE-S3. Everything else worth pointing at an S3 bucket takes your key.
So the rule of thumb survives, but with an asterisk: “a managed service that cannot use your key just has not been granted access” holds when the service uses your key at all. NLB does and ALB does not. The first thing to check before writing the policy is whether the service even calls KMS on the delivery path. For ALB, it never does, and no policy changes that.