Locking down S3: block public access and enforce encryption
Turn on account-wide Block Public Access, require TLS and encryption with a bucket policy, and verify it.
Most S3 hardening guides were written before 2023 and spend half their length on steps a new bucket no longer needs. A bucket created today does not allow public access, has ACLs disabled, and encrypts every new object with SSE-S3 whether or not the client asked. What is left is the part the defaults cannot do: the account-level switch that stops a future policy edit from undoing them, the choice of KMS over SSE-S3 where key control matters, a bucket policy that refuses cleartext without locking out AWS services, and a way to prove all of it for the buckets that were created in 2019 and never looked at since.
What a new bucket already has, and what it does not
| Control | New bucket today | Still your job |
|---|---|---|
| Block Public Access | public access not allowed by default | turn it on at the account (and Organizations) level so a bucket policy edit cannot switch it off |
| ACLs | disabled (BucketOwnerEnforced) | older buckets may still have ACLs enabled; check and disable |
| Encryption at rest | SSE-S3 on every new object since 2023-01-05 | decide where SSE-KMS is required; objects written before 2023 may be unencrypted |
| Encryption in transit | nothing enforced; HTTP is accepted | a bucket policy denying aws:SecureTransport = false and old TLS |
| Who can read | the account’s IAM decides | least-privilege policies; BPA does not restrict principals in your own account |
| Evidence | none | access logs or CloudTrail data events, Access Analyzer, a scheduled check |
The switch that has to be at the account level
Bucket-level Block Public Access is a setting on the bucket, and the documentation is explicit about why that is not enough: a principal allowed to edit the bucket policy can also change the bucket's public access settings. Set at the account level, BlockPublicPolicy makes S3 reject a public policy even after a bucket-level setting is altered, and an Organizations-level policy does the same for every account in the OU. The bucket-level call remains useful as a belt for the buckets that matter most; the account-level call is the braces.
ACCOUNT=111122223333BUCKET=acme-app-data# account level: every bucket, present and future, in this accountaws s3control put-public-access-block --account-id "$ACCOUNT" \--public-access-block-configuration \BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true# bucket level: the same four flags, so the bucket is safe even if moved to another accountaws s3api put-public-access-block --bucket "$BUCKET" \--public-access-block-configuration \BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true# ACLs off: access is decided by policies only (already the default for new buckets)aws s3api put-bucket-ownership-controls --bucket "$BUCKET" \--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
Encryption: the decision is KMS or not
SSE-S3 is already applied, costs nothing and needs no key management, and for most application data it is the right answer. SSE-KMS earns its extra API calls and cost in three situations: a key policy that must be separate from the bucket policy (a second control on who can decrypt), an audit trail of every decrypt in CloudTrail, and cross-account access where the key policy is the boundary. When SSE-KMS is chosen, set it as the bucket default with an S3 Bucket Key so that S3 requests one data key per bucket-key period instead of one per object, and pin the key so a client cannot substitute another.
aws s3api put-bucket-encryption --bucket acme-app-data --server-side-encryption-configuration '{ "Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms", "KMSMasterKeyID":"arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"}, "BucketKeyEnabled":true}]}'aws s3api get-bucket-encryption --bucket acme-app-data --query "ServerSideEncryptionConfiguration.Rules[0]"{ "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "arn:aws:kms:…" }, "BucketKeyEnabled": true }existing objects keep the encryption they were written with; re-encrypt with a copy-in-place or S3 Batch OperationsThe bucket policy: TLS only, without locking out AWS
{"Version": "2012-10-17","Statement": [{"Sid": "DenyCleartext","Effect": "Deny","Principal": "*","Action": "s3:*","Resource": ["arn:aws:s3:::acme-app-data", "arn:aws:s3:::acme-app-data/*"],"Condition": {"Bool": { "aws:SecureTransport": "false", "aws:PrincipalIsAWSService": "false" }}},{"Sid": "DenyOldTls","Effect": "Deny","Principal": "*","Action": "s3:*","Resource": ["arn:aws:s3:::acme-app-data", "arn:aws:s3:::acme-app-data/*"],"Condition": {"NumericLessThan": { "s3:TlsVersion": 1.2 },"Bool": { "aws:PrincipalIsAWSService": "false" }}},{"Sid": "DenyOtherKmsKeys","Effect": "Deny","Principal": "*","Action": "s3:PutObject","Resource": "arn:aws:s3:::acme-app-data/*","Condition": {"StringNotEqualsIfExists": {"s3:x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"}}}]}
The second condition key in DenyCleartext is the one most copies of this policy lack. When an AWS service calls S3 on your behalf, the network context of that call, aws:SecureTransport included, is redacted, and a plain deny on SecureTransport = false can block the service principal. The documentation's fix is aws:PrincipalIsAWSService: false in the same condition, so the deny applies to clients and not to services. DenyOtherKmsKeys uses IfExists deliberately: a client that names a different key is refused, a client that names none is not, and the bucket default supplies the right key. Enable S3 server access logging or CloudTrail data events on the bucket before attaching a deny; the first thing a new deny does is reveal the client nobody knew was using HTTP.
Prove it, then find the buckets that predate the defaults
aws s3control get-public-access-block --account-id 111122223333 --query PublicAccessBlockConfiguration{ "BlockPublicAcls": true, "IgnorePublicAcls": true, "BlockPublicPolicy": true, "RestrictPublicBuckets": true }aws s3api get-bucket-policy-status --bucket acme-app-data{ "PolicyStatus": { "IsPublic": false } }aws s3api get-bucket-ownership-controls --bucket acme-app-data --query "OwnershipControls.Rules[0].ObjectOwnership""BucketOwnerEnforced"curl -sI http://acme-app-data.s3.eu-west-1.amazonaws.com/ | head -1HTTP/1.1 403 Forbiddenfor b in $(aws s3api list-buckets --query "Buckets[].Name" --output text); do o=$(aws s3api get-bucket-ownership-controls --bucket "$b" --query "OwnershipControls.Rules[0].ObjectOwnership" --output text 2>/dev/null || echo NONE) [ "$o" = BucketOwnerEnforced ] || echo "$b: $o"; donelegacy-exports-2019: ObjectWritermarketing-assets: NONEbuckets created before the defaults changed, still evaluating ACLs; each one needs the ownership change and a look at who relied on itThe loop at the end is the useful part of the runbook. The account has buckets older than every default in the table, and each one keeps the settings it was created with. IAM Access Analyzer reports which of them are reachable from outside the account, S3 Storage Lens counts the ones without SSE-KMS where that was the requirement, and an AWS Config rule such as s3-bucket-ssl-requests-only turns the policy check into something that runs without a person.
Buckets built in the console are the ones most likely to be missing from this list, and importing them into Terraform is how their settings become reviewable; once they are code, a policy scan refuses the next public ACL before it is applied rather than after Access Analyzer notices.