S3 data protection
Block Public Access, TLS/encryption policies, Object Lock.
Almost every S3 bucket breach you've read about had the same root cause: a bucket that was meant to be private wasn't. No zero-day. No cracked encryption. Someone set an old-style permission wrong, or pasted a policy with a wildcard in it, and a folder of customer records turned into a public web address that an automated scanner found within hours. The fix isn't cleverness. It's a short stack of boring controls, each one built to catch the moment another one fails.
Treat a bucket like a filing cabinet in a busy office. By default it should be locked. Only staff with the right keycard get near it, the papers inside are written in a code that only the finance team can read even if someone walks off with a page, and there's one drawer of legal records that can't be shredded until a date stamped on the front. AWS gives you a real switch for each of those ideas. Most incidents happen because someone wrote those switches into a design doc and never actually flipped them.
Private by default, and actually verified
Block Public Access, usually shortened to BPA, is the master lock on the cabinet. It's four settings that, when switched on, ignore or reject anything that would expose the bucket to the public, no matter what some individual policy or legacy ACL claims. ACL there means access control list, an older per-object permission style that predates bucket policies and sits behind a large share of accidental leaks. With all four flags on, a public ACL is ignored and a policy that grants public access is rejected outright.
Here's the part the audit kept flagging. You can set BPA on a single bucket and feel safe, but the real backstop is account-level BPA, set once for the whole account. It covers every bucket you have now and every bucket anyone creates later, and nothing beneath it can override it. Since April 2023 AWS turns BPA on for new buckets by default, but older buckets predate that and the per-bucket setting can still be switched off by anyone with permission. The account-level switch is the one no single mistake can undo. So set it, then read it back, because a setting you didn't verify is a setting you're guessing at.
$ aws s3api put-public-access-block --bucket acme-data \--public-access-block-configuration \BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true$ echo $?0$ aws s3api get-public-access-block --bucket acme-data{"PublicAccessBlockConfiguration": {"BlockPublicAcls": true,"IgnorePublicAcls": true,"BlockPublicPolicy": true,"RestrictPublicBuckets": true}}$ aws s3control put-public-access-block --account-id 222222222222 \--public-access-block-configuration \BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true$ echo $?0
put-public-access-block prints nothing and returns exit code 0 when it works, which is exactly why the get call matters: four trues is the confirmation. The s3control command does the same thing one level up, across the entire account, so a single bad ACL on any one bucket can't reopen public access for the estate.
Locked in transit, locked at rest
Blocking public access keeps strangers out of the cabinet. It does nothing about someone reading a file while it's being carried across the office. That's the job of TLS, Transport Layer Security, the same lock that shows the padlock in your browser. It wraps data moving between a client and S3 so nobody on the network can read it or change it. S3 speaks TLS happily, but out of the box it still accepts plain, unencrypted connections too. You shut that door with a bucket policy that denies any request where the condition aws:SecureTransport is false.
At rest, you want every stored object encrypted with a key you control. SSE is server-side encryption, which just means S3 scrambles the bytes on disk for you. Every new object already gets a baseline of S3-managed encryption for free, but that key belongs to AWS, not you. SSE-KMS changes that. The KMS part is the Key Management Service, and it hands you the key instead. You hold the key policy. Every decrypt shows up in the audit trail, and if a key ever has to be pulled, disabling that one key locks everyone out at once (the previous lesson goes deep on how that works). A second statement in the policy rejects any upload that doesn't ask for SSE-KMS, so nobody can quietly store an object under AWS's default key instead of yours and slip out of that audit trail.
{"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" } } }]}$ aws s3api put-bucket-policy --bucket acme-data --policy file://tls-sse.json$ echo $?0$ aws s3api put-object --bucket acme-data --key report.csv --body report.csvAn error occurred (AccessDenied) when calling the PutObject operation: Access Denied$ aws s3api put-object --bucket acme-data --key report.csv --body report.csv \--server-side-encryption aws:kms --ssekms-key-id alias/acme-data{"ETag": "\"b58f2c3e9a1d47f08c6e2b9a4d3f1e6c\"","ServerSideEncryption": "aws:kms","SSEKMSKeyId": "arn:aws:kms:eu-west-1:222222222222:key/1a2b3c4d-5e6f-7890-abcd-ef1234567890"}
The unencrypted upload comes back AccessDenied even though that principal is otherwise allowed to write. Add the SSE-KMS flag and the identical call succeeds and hands back an ETag. From now on the policy enforces both rules on every request, with no human in the loop to forget one.
The drawer that can't be shredded
Some data has to survive the people who want it gone, including a compromised admin trying to erase their tracks. Audit logs and financial records. Anything a court might ask for a year from now. Object Lock is that stamped drawer: once an object version is written with a retention date, no one deletes or overwrites it until the clock runs out. The old name for this is WORM, write once read many.
There are two lock modes, and the gap between them is the whole point. GOVERNANCE mode lets a role that holds the s3:BypassGovernanceRetention permission lift the lock early, which is fine when you might legitimately need to correct a mistake. COMPLIANCE mode has no escape hatch. An admin can't shorten the retention. The account root user can't either. Not even AWS support can delete the object before its date; the only way out is closing the whole AWS account. That's what you want under a real audit-log bucket, because evidence you can trust means even the most powerful person in the account can't rewrite it after the fact. Object Lock rides on top of versioning, so the bucket needs versioning switched on. You can turn Object Lock on when you create the bucket, or add it to an existing versioned bucket later, but once it's on you can't turn it back off.
$ aws s3api put-object-lock-configuration --bucket acme-audit-logs \--object-lock-configuration \'{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":2555}}}'$ echo $?0$ aws s3api get-object-lock-configuration --bucket acme-audit-logs{"ObjectLockConfiguration": {"ObjectLockEnabled": "Enabled","Rule": {"DefaultRetention": { "Mode": "COMPLIANCE", "Days": 2555 }}}}$ aws s3api get-object-retention --bucket acme-audit-logs --key 2026/07/audit.log.gz{"Retention": {"Mode": "COMPLIANCE","RetainUntilDate": "2033-07-14T00:00:00+00:00"}}$ aws s3api delete-object --bucket acme-audit-logs \--key 2026/07/audit.log.gz --version-id 3sL4kqtA0pRvE9mfd8xY1nZ.pQ9An error occurred (AccessDenied) when calling the DeleteObject operation: Access Denied
2555 days is roughly seven years. The delete of that specific version bounces with AccessDenied, and get-object-retention shows exactly why: a RetainUntilDate in 2033 that COMPLIANCE mode won't let anyone move. That's the difference between a log you hope is intact and one you can prove is.
Every control here assumes you already know which buckets hold the sensitive material. Across a real estate of thousands of buckets, you don't, not by reading them by hand. Finding and classifying that data automatically, so the right buckets get the strongest locks, is where Macie picks up next.
Try this
Work through “The drawer that can't be shredded” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: object Lock is a bill you can't delete your way out of. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.