S3 data protection

Block Public Access, TLS/encryption policies, Object Lock.

Advanced30 min · lesson 8 of 15

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.

one bucket, then the whole account: block public access and verify
$ 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.

bucket policy: no plaintext transport, no 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" } } }
]
}
$ 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.csv
An 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.

Object Lock in COMPLIANCE mode, and a delete that bounces
$ 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.pQ9
An 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.

One risky request, four different controls that stop it
A risky request reaches acme-data
each layer is built to catch a different specific mistake
public ACL or policy
Block Public Access
account-level setting overrides it, so the request is denied
plain HTTP, no TLS
aws:SecureTransport deny
the bucket policy refuses any unencrypted connection
unencrypted upload
SSE-KMS deny
the object is rejected unless it asks for your KMS key
delete an audit log
Object Lock (COMPLIANCE)
immutable until the retain-until date, even for the root user
No single control is clever. Stacked, they turn the public-object or tampering mistake into a request that just gets denied.
Object Lock is a bill you can't delete your way out of
COMPLIANCE mode is genuinely irreversible, and that cuts both ways. Put a seven-year default retention on a high-churn bucket and every object written to it becomes storage you keep paying for until its date, with no way to clean up early, not even for junk a buggy writer looped in overnight. Reserve long retention for the buckets that truly need it, like audit-log sinks, and test the whole setup on a bucket with a short retention of a day or two before you commit years of production data to it.
Quick check
01You put Object Lock in COMPLIANCE mode with a 7-year retention on your audit-log bucket. Weeks later, an engineer with full admin rights (and even the account root user) needs to delete a log object written last week. What actually happens?
Incorrect — COMPLIANCE mode has no override. Root is not special here, which is the entire reason the mode exists.
Correct — COMPLIANCE mode removes every escape hatch, which is what makes the log trustworthy as evidence.
Incorrect — You can't downgrade a COMPLIANCE lock to GOVERNANCE to escape it. That path is deliberately closed.
Incorrect — There is no soft-delete here. The version is fully protected and the delete call itself is denied.
02You have already enabled Block Public Access on each sensitive S3 bucket individually. The lesson still insists you also set Block Public Access at the account level (with the s3control command). What does the account-level setting give you that the per-bucket settings do not?
Incorrect — Block Public Access governs public exposure, not encryption at rest; controlling the key is what SSE-KMS does.
Correct — the account-level switch is the backstop no single mistake can undo, covering current and future buckets alike.
Incorrect — per-bucket Block Public Access carries all four flags and already ignores public ACLs; the account level's advantage is scope, not ACL coverage.
Incorrect — Object Lock is a separate, opt-in feature per bucket and has nothing to do with Block Public Access.
03A bucket policy denies any s3:PutObject where s3:x-amz-server-side-encryption is not aws:kms. A service that is otherwise allowed to write runs put-object with no encryption flags and gets AccessDenied. What is the fix, and why is the policy written this way?
Incorrect — the principal is already allowed to write; an explicit Deny on the missing encryption header is what blocks it, not a lack of PutObject.
Incorrect — Block Public Access governs public exposure and has no bearing on whether an authenticated encrypted upload is accepted.
Correct — the identical call succeeds once it asks for SSE-KMS, and the deny is there to force every object onto your audited key rather than AWS's default one.
Incorrect — versioning is unrelated to the encryption condition and turning it off would not satisfy the SSE-KMS requirement.

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.

Related