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.

Jul 2, 2025·Updated ·6 min readBeginner·By SecOpsLog · documentation-verified

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

ControlNew bucket todayStill your job
Block Public Accesspublic access not allowed by defaultturn it on at the account (and Organizations) level so a bucket policy edit cannot switch it off
ACLsdisabled (BucketOwnerEnforced)older buckets may still have ACLs enabled; check and disable
Encryption at restSSE-S3 on every new object since 2023-01-05decide where SSE-KMS is required; objects written before 2023 may be unencrypted
Encryption in transitnothing enforced; HTTP is accepteda bucket policy denying aws:SecureTransport = false and old TLS
Who can readthe account’s IAM decidesleast-privilege policies; BPA does not restrict principals in your own account
Evidencenoneaccess 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.

harden-bucket.sh
ACCOUNT=111122223333
BUCKET=acme-app-data
# account level: every bucket, present and future, in this account
aws 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 account
aws 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}]'
Block Public Access says nothing about your own principals
With every flag on, a principal in your account holding s3:GetObject on a wildcard resource still reads every object, and a role assumed from a compromised CI job reads them just as well. The controls in this post stop accidental publication and cleartext transport; the read path is decided by IAM policies and the KMS key policy, and that is where a data-access review has to look.

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.

bash — default SSE-KMS with a Bucket Key
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 Operations

The bucket policy: TLS only, without locking out AWS

bucket-policy.json
{
"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.

Go deeper in a courseAWS security for DevOps engineersAccount-level guardrails, KMS key policies, Access Analyzer and the detective controls around S3.View course

Prove it, then find the buckets that predate the defaults

bash — four reads and one negative test
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 -1
HTTP/1.1 403 Forbidden
for 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"; done
legacy-exports-2019: ObjectWriter
marketing-assets: NONE
buckets created before the defaults changed, still evaluating ACLs; each one needs the ownership change and a look at who relied on it

The 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.

Related posts