Subscribe

S3 in 2026: block public ACLs, deny, encrypt, no list

A locked teal bucket. A tipped coral bucket in the distance.

April 28, 2023 is the date AWS said every new bucket ships with Block Public Access on and ACLs off. January 5, 2023 is the date new objects get SSE-S3 whether you asked or not.

The control is a setting the CLI prints, a policy that fails closed, encryption that shows up on HeadObject, and a list action the internet cannot call.

Pair this page with the secure coding checklist for access control, with Ubuntu host hardening for the box that talks to the bucket, and with dependency hygiene when a leaked key is how the bucket was reached.

A public ACL on one object is how a private bucket still leaks. Stamp it out, keep Block Public Access on, and leave List off AppReadRole.

SecureCoding

What new buckets already do

I opened the April 28, 2023 what’s-new note. AWS wrote that the change was announced on December 13, 2022, began deploying on April 5, 2023, and then applied in every Region. Two settings: public access blocked, ACLs disabled. The News Blog heads-up names the ACL mode Bucket owner enforced. Object ACLs and bucket ACLs stop granting access. The owner of the bucket is the owner of the object.

I opened the default encryption FAQ. Starting January 5, 2023, every new upload is encrypted with SSE-S3, AES-256, at no extra charge. You cannot turn that base off. Existing objects in a bucket that had no default were not rewritten. New writes were.

April 6, 2026 is the next default I can cite with a first-party date. The Storage Blog advanced notice said SSE-C would be disabled on every new general purpose bucket, and on existing buckets in accounts that held no SSE-C objects. The SSE-C FAQ I opened in August 2026 says that update deployed in April 2026. A PutObject that still sends SSE-C headers to a blocked bucket gets HTTP 403 AccessDenied. If you still need customer-provided keys, you opt in with PutBucketEncryption after create. Most apps should stay on SSE-S3 or SSE-KMS and leave SSE-C off.

None of those defaults repair a bucket created in 2018 that still has a public ACL on one prefix. The rest of this page is the repair. A CloudFormation stack, a Terraform module, or a click in an old tutorial can still emit AccessControl: PublicRead. Treat every inherited bucket as pre-2023 until the reads say otherwise.

Turn BlockPublicAcls and friends on

Block Public Access is four booleans, not a mood. Bucket-level and account-level both exist. Account-level wins when it is stricter. The names below are the ones get-public-access-block prints.

FlagWhat it stops
BlockPublicAclsNew public ACLs on the bucket or an object
IgnorePublicAclsExisting public ACLs. They stay on the object. They stop granting
BlockPublicPolicyA new bucket policy that would grant public access
RestrictPublicBucketsCross-account and anonymous use of an already-public policy

Set every boolean on app-uploads. Repeat the same configuration at account scope so a later CreateBucket cannot skip them.

aws s3api put-public-access-block \
  --bucket app-uploads \
  --public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'

aws s3control put-public-access-block \
  --account-id 111122223333 \
  --public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'

Replace 111122223333 with the account you actually own. Then kill ACLs on that bucket if they are still live:

aws s3api put-bucket-ownership-controls \
  --bucket app-uploads \
  --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

BucketOwnerEnforced is the April 2023 default. ObjectWriter and BucketOwnerPreferred bring ACLs back. Do not set those to make an SDK from 2017 happy. Update the SDK. A canned public-read on one key still grants after the bucket looks private. BucketOwnerEnforced is the stamp-out.

Deny in the bucket policy

The flags stop public grants. They do not write your allow list. A role in this account can still GetObject if its identity policy says so. That is intended. What you add next is a Deny that makes an anonymous or foreign principal fail even if someone later pastes an Allow.

Attach this on app-uploads. Keep the name app-uploads in both Resource lines. Swap the account and the role for yours.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::app-uploads",
        "arn:aws:s3:::app-uploads/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    },
    {
      "Sid": "DenyWorldListAndGet",
      "Effect": "Deny",
      "Principal": "*",
      "Action": ["s3:ListBucket", "s3:GetObject"],
      "Resource": [
        "arn:aws:s3:::app-uploads",
        "arn:aws:s3:::app-uploads/*"
      ],
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalAccount": "111122223333"
        }
      }
    }
  ]
}

DenyWorldListAndGet keeps list and get inside this account. A CloudFront distribution in the same account still works if its OAC role is in 111122223333. A partner account does not. If you truly need a partner, name that account in the condition or name the role ARN. Do not switch the statement to Allow Principal * and hope the flags hold.

Apply it with put-bucket-policy. Then read it back. A policy you cannot get is not attached.

aws s3api put-bucket-policy --bucket app-uploads --policy file://app-uploads-policy.json
aws s3api get-bucket-policy --bucket app-uploads --query Policy --output text
aws s3api get-bucket-policy-status --bucket app-uploads

get-bucket-policy-status returns IsPublic. You want false. If it returns true, a statement still grants the world. Fix that statement. Do not hide it behind a console badge.

Encryption on HeadObject

SSE-S3 is already on new writes. Read it anyway. A bucket created before January 2023 can still show no default if someone later cleared the config, and the FAQ is explicit that old objects were not rewritten. Set the default. Prefer a KMS key you can disable when the role that reads invoices should stop.

aws s3api put-bucket-encryption --bucket app-uploads --server-side-encryption-configuration '{
  "Rules": [{
    "ApplyServerSideEncryptionByDefault": {
      "SSEAlgorithm": "aws:kms",
      "KMSMasterKeyID": "arn:aws:kms:us-east-1:111122223333:key/EXAMPLE"
    },
    "BucketKeyEnabled": true
  }]
}'

aws s3api get-bucket-encryption --bucket app-uploads

Replace EXAMPLE with a key you created for this bucket. The key policy must allow the same AppReadRole that the identity policy allows. A KMS Deny is a silent 403 on GetObject. That is a feature when you are revoking. It is a ticket when you forgot the grant.

Confirm a new object actually carries the algorithm:

echo "probe" | aws s3 cp - s3://app-uploads/invoices/_probe.txt \
  --sse aws:kms --sse-kms-key-id arn:aws:kms:us-east-1:111122223333:key/EXAMPLE
aws s3api head-object --bucket app-uploads --key invoices/_probe.txt \
  --query '[ServerSideEncryption, SSEKMSKeyId]'

Expect aws:kms and that key ARN. Delete invoices/_probe.txt when you are done. Leave SSE-C blocked unless a named vendor still sends customer keys and you opted that bucket back in on purpose.

Never let the world list

A public GetObject on a random key is bad. A public ListBucket is how the random key is found. People still paste Principal * with s3:ListBucket so a browser can render an index. That is a directory listing on the internet. The Deny above is the lock. This section is the proof that list is not public.

From a shell that has no AWS credentials, or from a role in another account you own:

aws s3api list-objects-v2 --bucket app-uploads --max-items 1
# Expect: AccessDenied, not a Contents array

curl -sS -o /dev/null -w "%{http_code}\n" \
  "https://app-uploads.s3.amazonaws.com/?list-type=2"
# Expect: 403

A 200 with XML keys means the Deny missed s3:ListBucket or the flags are off. Fix the policy. Do not add a robots.txt. S3 is not a web server you own; the Ubuntu guide is for the host that holds the CLI. This page is for the bucket the host calls.

Static assets that must be on the public web do not live in app-uploads. They live in a second bucket that you treat as a CDN origin, still with public access blocked, still without a world list, with CloudFront OAC as the only reader. Two buckets. Two policies. One of them never faces the internet.

A minimal origin-access statement on that CDN bucket names the CloudFront service principal and your distribution ARN. It does not name Principal *. The check is the same: get-bucket-policy-status stays IsPublic false, and an anonymous list-objects-v2 is still AccessDenied.

Prove the four reads

You are reading your own account. You are not scanning other people.

  1. Run get-public-access-block on app-uploads. All four values must be true.
  2. Run the same call at the account with s3control. Same four true.
  3. Run get-bucket-ownership-controls. Expect BucketOwnerEnforced.
  4. Run get-bucket-encryption and the head-object from the encryption section.
  5. Run get-bucket-policy-status. IsPublic must be false.
  6. Run the unauthenticated list-objects-v2 from the list section. Expect AccessDenied.
aws s3api get-public-access-block --bucket app-uploads
aws s3control get-public-access-block --account-id 111122223333
aws s3api get-bucket-ownership-controls --bucket app-uploads
aws s3api get-bucket-encryption --bucket app-uploads
aws s3api get-bucket-policy-status --bucket app-uploads

I could not confirm that every marketplace CloudFormation stack you inherited still creates buckets with the April 2023 defaults. A stack from 2022 can still emit a public ACL. The five reads above are the effective config. If they disagree with the template, the template lost or never applied. Fix the live bucket, then the template.

IAM Access Analyzer has an S3 finding type for buckets that allow public or cross-account access. Turn an analyzer on in this account and treat a finding on app-uploads as a failed read, not as a dashboard badge. Macie can classify objects after the fact. It does not close ListBucket. Run the analyzer after the policy change, not instead of it.

S3 Inventory and Storage Lens will show encryption and public-access metrics if you turn them on. They are a fleet view. They are not a substitute for the reads on the one bucket that holds invoices. A weekly inventory CSV that still lists SSEAlgorithm empty on old objects is the rewrite job the January 2023 FAQ warned about. New writes follow the default. Old objects do not move themselves.

Versioning plus a deny on s3:DeleteObjectVersion for everyone but a named break-glass role is how you survive a confused aws s3 rm. That is durability, not public access. Mentioning it is honest. It does not replace DenyWorldListAndGet.

Questions we keep getting

Do I still need a bucket policy after public access is blocked?

Yes. Those booleans block public grants. They do not name AppReadRole. They do not deny HTTP. They do not deny ListBucket from a confused identity in your own account that you never intended to read invoices. Write the Deny. Keep the block enabled.

Is SSE-S3 enough, or do I need KMS?

SSE-S3 is the base AWS already applies. Use SSE-KMS when you want a disable switch and a CloudTrail event on decrypt. Use a customer managed key, not the aws/s3 alias, if revocation is the point.

Can I make one prefix public and keep the rest private?

Not with a public ACL, and not by turning the flags off. Split the prefix into another bucket behind CloudFront, or mint a time-bounded signed URL from code you own. A mixed-ACL bucket is the 2018 failure mode.