AWS quietly changed the default security posture of every new S3 bucket twice in a five-month span: on January 5, 2023, it began applying SSE-S3 (AES-256) server-side encryption to all new object uploads by default, and on April 2023, it made S3 Block Public Access and disabled ACLs the default for every newly created bucket, regardless of whether it's created through the console, the CLI, an SDK, or CloudFormation. Both changes were real improvements, and both are frequently misunderstood as retroactive. They are not. Buckets created before those dates keep whatever configuration they already had, and neither change stops a well-meaning engineer from attaching a permissive bucket policy, disabling the new defaults, or uploading objects that were already unencrypted before the cutover. S3 remains one of the most common sources of cloud data exposure precisely because "default" and "correct" are not the same thing, and a single overridden setting on one bucket out of a few hundred is enough to expose customer records, backups, or Terraform state files to the public internet. This post lays out a concrete hardening checklist — public access, policy scoping, encryption, logging, and durability — with working Terraform for each control, so you can audit what you actually have instead of assuming AWS's newer defaults already cover you.
Does S3 block public access by default now, and is that enough?
New buckets get Block Public Access (BPA) and disabled ACLs by default since April 2023, but BPA is an account- and bucket-level setting that can be explicitly turned off, and it does nothing for buckets created before the change rolled out. BPA is actually four separate controls — block new public ACLs, remove existing public ACLs, block new public bucket policies, and restrict public/cross-account access granted by public policies — and all four should be enabled at both the account level and on every individual bucket, since a bucket-level override can still punch a hole even when the account default is locked down. The practical failure mode isn't usually someone flipping BPA off outright; it's a bucket created years before April 2023 that nobody re-audited, or a policy with a wildcard principal ("Principal": "*") that BPA's public-policy check should catch but that teams sometimes discover was never enabled in the first place because the bucket predates the account-level default.
What should a bucket policy actually deny, not just allow?
A hardened bucket policy should explicitly deny two things every S3 bucket is exposed to by default: unencrypted requests and non-TLS traffic. A Deny statement with the condition "aws:SecureTransport": "false" rejects any request made over plain HTTP, and a second Deny statement checking "s3:x-amz-server-side-encryption" rejects PUT requests that don't specify encryption — both are things IAM allow-rules can't express, since IAM only grants permissions, it never forbids requests that satisfy some other identity's grant. Bucket policies should also scope Principal to specific account IDs or IAM role ARNs rather than the AWS account root, and avoid NotPrincipal and NotAction constructs, which are easy to get backwards and end up granting more access than intended. The general rule: IAM policies describe what your own roles can do, and the bucket policy is the last line of defense describing what any principal, including ones outside your account, is allowed to do to that specific bucket — treat it as the control that has to hold even if an IAM policy elsewhere is wrong.
Is server-side encryption by default actually sufficient?
Default SSE-S3 encryption, active since January 5, 2023, covers new object uploads with AES-256 keys AWS manages entirely — it does not encrypt objects that existed before that date, and it gives you no visibility into who accessed the underlying key material. As engineer Yan Cui pointed out in his analysis of the change shortly after it shipped, the default only handles new uploads going forward, so any bucket populated before January 2023 can still contain unencrypted objects that need an explicit re-encryption pass. For workloads under compliance requirements that call for an audit trail of key usage — PCI DSS, HIPAA, or SOC 2 obligations around access control on sensitive data — SSE-KMS with a customer-managed key is the stronger choice, because every decrypt and encrypt call against that key shows up in CloudTrail, while SSE-S3 key usage is invisible to you by design. The tradeoff is cost and latency: KMS charges per API call and adds a network hop, which matters at very high request volumes.
How do you know who actually accessed a bucket?
Neither S3 server access logging nor CloudTrail data events are enabled by default, and they answer different questions. Server access logs, delivered as flat files to a separate logging bucket, record every object-level request — GET, PUT, DELETE — with the requester and source IP, but delivery can lag by hours and the format is a raw log line you have to parse yourself. CloudTrail data events log the same API calls in near-real time in structured JSON, integrate with CloudWatch alarms and Security Hub, and give you management-plane context like the IAM role and session used, but they cost more at scale since every S3 API call is billed as a data event. Most teams should run both on any bucket holding regulated or customer data: access logs as the cheap, always-on record for post-incident forensics, and CloudTrail data event alarms for real-time detection of anomalous access patterns like a role suddenly listing every object in a bucket it's never touched before.
What else protects against deletion, ransomware, and accidental overwrite?
Versioning, MFA Delete, and S3 Object Lock protect against the failure modes encryption and access control don't touch: accidental overwrite, a compromised credential deleting objects, or a ransomware actor encrypting and replacing your data in place. Versioning alone lets you recover a prior copy of an object after an overwrite or delete, but a sufficiently privileged attacker can just delete the old versions too — MFA Delete closes that gap by requiring a hardware or virtual MFA code to permanently delete a version or suspend versioning itself. Object Lock, used in either governance or compliance mode, goes further by making objects genuinely immutable for a retention period even to the account root user, which is the property backup and financial-record buckets typically need to satisfy retention regulations. None of these three replace the earlier controls — they're the layer that assumes an attacker or a mistake gets past everything else, and asks whether the data can still be destroyed.
What does this look like in Terraform?
A minimally hardened bucket combines BPA, a deny-by-default policy, default encryption, versioning, and logging in a handful of resources:
resource "aws_s3_bucket" "data" {
bucket = "example-hardened-bucket"
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
mfa_delete = "Enabled"
}
}
resource "aws_s3_bucket_logging" "data" {
bucket = aws_s3_bucket.data.id
target_bucket = aws_s3_bucket.logs.id
target_prefix = "access-logs/"
}
resource "aws_s3_bucket_policy" "deny_insecure" {
bucket = aws_s3_bucket.data.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyNonTLS"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = ["${aws_s3_bucket.data.arn}", "${aws_s3_bucket.data.arn}/*"]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
},
{
Sid = "DenyUnencryptedUploads"
Effect = "Deny"
Principal = "*"
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.data.arn}/*"
Condition = { StringNotEquals = { "s3:x-amz-server-side-encryption" = "aws:kms" } }
}
]
})
}
This is a starting point, not a complete policy — production buckets still need scoped principals, lifecycle rules, and Object Lock where retention rules apply, but every resource above addresses a control this checklist covers and none of it relies on AWS's newer defaults alone to hold.