No AWS service has caused more data-leak headlines than S3. The pattern is always the same: a bucket that should have been private is reachable by anyone with the URL, and a scraper finds it before the owner does. AWS has spent years making this harder — new buckets have blocked public access and default encryption since 2023, and ACLs are disabled by default — but the leaks continue, because existing buckets carry old settings, cross-account grants bypass the obvious controls, and a single overly broad bucket policy undoes every default. This guide is a complete, practical walkthrough of S3 security, ending with Terraform you can drop into a module so every bucket is born secure.
The Anatomy of an S3 Leak
An S3 bucket can become public through several independent paths, which is why "I didn't make it public" is so often wrong:
- A bucket policy with
"Principal": "*"and no restricting condition. - A legacy ACL granting
AllUsersorAuthenticatedUsers(mostly mitigated now that ACLs are off by default, but present on older buckets). - An account or bucket that disabled Block Public Access to serve a static site and never re-scoped.
- A cross-account grant that's technically not "public" but exposes data to an untrusted account.
Securing S3 means closing all of these, not just the one you remember.
Layer 1: Block Public Access
Block Public Access (BPA) is the master switch. Enable it at the account level so no bucket, existing or future, can be made public by accident, then leave it on at the bucket level too. If a specific bucket genuinely must serve public content (a static website), scope the exception to that one bucket rather than disabling BPA account-wide.
# Enable account-level Block Public Access
aws s3control put-public-access-block \
--account-id 111122223333 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Layer 2: Encryption at Rest
New buckets apply SSE-S3 encryption by default, but for sensitive data use SSE-KMS with a customer-managed key so you control rotation and can revoke access by disabling the key. This also gives you an audit trail — every decrypt is a CloudTrail event tied to the KMS key.
Layer 3: A Least-Privilege Bucket Policy
The bucket policy is where leaks are most often introduced and most often fixed. Two rules cover most of the risk: deny any request not using TLS, and never grant to * without a tight condition. This policy enforces TLS and denies unencrypted uploads:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::acme-data", "arn:aws:s3:::acme-data/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
},
{
"Sid": "DenyUnencryptedUploads",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::acme-data/*",
"Condition": { "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" } }
}
]
}
Layer 4: Logging and Versioning
Enable server access logging or CloudTrail data events so you can reconstruct who accessed what during an incident. Enable versioning plus MFA delete on critical buckets so an attacker (or a mistake) can't silently overwrite or delete objects. Add lifecycle rules to expire old versions and control cost.
Put It Together: Secure-by-Default in Terraform
The reliable way to keep every bucket secure is to make secure the only option — a module that bundles all four layers so a developer can't create an insecure bucket:
resource "aws_s3_bucket" "this" {
bucket = var.name
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.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" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
}
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration { status = "Enabled" }
}
Scanning this module in CI guarantees the settings can't be weakened in a future edit — which is exactly what infrastructure-as-code scanning enforces.
S3 Security Checklist
| Layer | Control | Enforcement |
|---|---|---|
| Public access | Account-level BPA on | Deny at org level |
| Encryption | SSE-KMS with CMK | Bucket policy denies unencrypted PUT |
| Transport | TLS required | aws:SecureTransport deny |
| Policy | No * without tight condition | IaC scan in CI |
| Logging | Access logging / CloudTrail data events | On for sensitive buckets |
| Integrity | Versioning + MFA delete | On for critical buckets |
How Safeguard Helps
Safeguard catches the S3 misconfigurations that cause leaks in the pull request that introduces them. Its IaC scanning runs Trivy's misconfiguration engine over your Terraform and CloudFormation to flag buckets missing Block Public Access, buckets without encryption, policies that grant to *, and missing TLS enforcement — mapped to the exact line so a developer fixes it before merge, not after a scraper finds the data. Because it runs through the pipeline-native CLI, you can gate merges so an insecure bucket definition can't ship at all. That build-time prevention complements runtime posture tools that would only flag the same bucket after it's already public; the Safeguard vs Wiz comparison explains how the two vantage points fit together.
S3 leaks are almost never sophisticated — they're a defaulted setting or an over-broad policy. Enable Block Public Access account-wide, encrypt with your own keys, keep bucket policies tight, and enforce all of it in code so secure is the only way to create a bucket.
Ready to stop S3 misconfigurations before they ship? Create a free Safeguard account or read the documentation to scan your S3 infrastructure-as-code.