Securing S3 buckets: a checklist that actually helps
Block Public Access, policies, encryption, and the misconfigurations that cause breaches.
The phrase "publicly exposed S3 bucket" has launched a thousand breach headlines, and every time I read one I think about how avoidable it was. S3 is secure by default now, buckets are private out of the box, yet misconfigurations still leak data because people punch holes for convenience and forget them. This is the checklist I actually run against a bucket, ordered by how much grief each item saves you.
1. Block Public Access at the account level
The single highest-leverage control. Account-level Block Public Access (BPA) overrides any bucket policy or ACL that would grant public access, so even a fat-fingered "Principal": "*" can't expose data. Turn on all four settings at the account level and you've closed the most common failure mode entirely.
aws s3control put-public-access-block \
--account-id 123456789012 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true
If a bucket genuinely needs to serve public content, front it with CloudFront and an Origin Access Control, the bucket stays private and only CloudFront can read it. A truly public bucket is almost never the right answer.
2. Encrypt, and control the keys
All new buckets enforce SSE-S3 encryption by default, but for sensitive data I use SSE-KMS with a customer-managed key. The benefit isn't just encryption at rest, it's that the KMS key policy becomes a second, independent authorization layer. Someone needs both S3 permissions and kms:Decrypt on the key to read the objects, which contains the blast radius of a leaked IAM credential.
3. Write deny-by-default bucket policies
Identity policies grant; bucket policies should also explicitly deny the things you never want. Two clauses I add to every sensitive bucket, deny any request not using TLS, and deny unencrypted uploads:
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::acme-secrets",
"arn:aws:s3:::acme-secrets/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
}
An explicit Deny always wins over any Allow, so these clauses are absolute guarantees regardless of what an identity policy says.
4. Turn on versioning and Object Lock for what matters
Security isn't only about confidentiality, it's also about not losing data to a ransomware-style delete. Versioning keeps prior versions so an overwrite or delete is recoverable. For compliance or truly critical data, Object Lock in compliance mode makes objects immutable for a retention period that not even the root account can override.
5. Make access observable
You can't secure what you can't see. I enable:
- CloudTrail data events on the bucket so object-level
GetObject/PutObjectcalls are logged. - S3 Access Logs or CloudTrail for an audit trail of who touched what.
- IAM Access Analyzer for S3, which continuously flags any bucket that grants access outside your account, the early-warning system for accidental exposure.
6. Scope IAM to prefixes, not whole buckets
The lazy policy is s3:GetObject on arn:aws:s3:::bucket/*. The right one scopes to the prefix a workload actually needs, e.g. arn:aws:s3:::bucket/team-a/*, and uses s3:prefix conditions on ListBucket. This keeps a compromised application from reading the whole bucket.
Takeaways
- Enable account-level Block Public Access first, it neutralizes the single most common S3 breach cause.
- Use SSE-KMS with a customer-managed key so KMS becomes a second authorization layer on top of S3 permissions.
- Add explicit deny clauses for non-TLS and unencrypted requests; an explicit Deny beats any Allow.
- Turn on CloudTrail data events and IAM Access Analyzer so accidental exposure surfaces before an attacker finds it.