IAnswerable

Make an S3 Bucket Public the Right Way

26 Dec 2025

You set a bucket policy allowing public reads, and objects still 403. Almost always it’s Block Public Access, not the policy you just wrote.

The two separate mechanisms

S3 access control is genuinely layered, and each layer can override the one below it:

  1. Block Public Access (BPA) — an account- and bucket-level setting that overrides everything else when enabled. Four separate toggles, and one of them silently ignores public bucket policies even if the policy itself is correct.
  2. Bucket policy — a JSON document granting or denying access at the bucket or prefix level.
  3. ACLs — legacy, per-object or per-bucket permission lists. AWS disables ACLs by default on new buckets now (Object Ownership set to “Bucket owner enforced”), and you should leave them disabled. If ACLs are enforced-off, any ACL you try to set is ignored — which is a common source of “I set the ACL and nothing changed.”

If you’re troubleshooting a 403, check BPA first. It’s the one setting that makes an otherwise-correct policy do nothing.

Making a bucket (or prefix) actually public

Turn off the specific BPA setting that blocks bucket policies:

aws s3api put-public-access-block \
  --bucket my-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=false

Then attach a policy scoped to exactly what should be public — never the whole bucket unless every object in it genuinely should be:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PublicReadForAssets",
    "Effect": "Allow",
    "Principal": "*",
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::my-bucket/public-assets/*"
  }]
}

Note it’s GetObject only, and scoped to a prefix. This makes only public-assets/* readable by anyone with the URL — nothing else in the bucket, and no write or delete access.

The 403 checklist, in order

  1. BPA — is BlockPublicPolicy or RestrictPublicBuckets still on? Either blocks the policy from taking effect.
  2. Bucket policy — does the Resource ARN actually match the object’s path, including the prefix? A trailing-slash mismatch or missing /* silently scopes the policy to nothing.
  3. Object Ownership / ACLs — if ACLs are somehow still enabled and an individual object has a restrictive ACL, that can deny access even with a permissive bucket policy.
  4. Encryption with KMS — if the object is encrypted with a customer-managed KMS key, public bucket policy access isn’t enough; the requester would need kms:Decrypt too, which anonymous requests can’t have. Public objects need to use SSE-S3 (AES256), not SSE-KMS.

As a flowchart:

flowchart TD
    A["403 on a supposedly public object"] --> B{"BPA still blocking?"}
    B -->|Yes| C["Disable the specific BPA toggle"]
    B -->|No| D{"Policy Resource ARN matches the path?"}
    D -->|No| E["Fix the ARN or add missing /*"]
    D -->|Yes| F{"ACL or SSE-KMS blocking it?"}
    F -->|Yes| G["Disable the ACL, or use SSE-S3 not KMS"]
    F -->|No| H["Should be public - check OAC / CloudFront next"]
  

Don’t make the bucket public if you don’t have to

For anything that’s not meant to be public forever, don’t flip BPA off at all. Serve through CloudFront with Origin Access Control instead — the bucket stays fully private, CloudFront is the only principal allowed to read from it, and you get caching and a custom domain for free. This is the more common correct answer once you’re serving through a CDN anyway, which most production static-asset setups are.

Gotcha

Turning off BPA is account-default-inheritable in some setups but bucket-level settings always take precedence over the account-level default when both exist. If you disabled BPA at the account level expecting it to apply everywhere and one bucket still 403s, check whether that specific bucket has its own BPA configuration overriding the account setting — new buckets sometimes get created with BPA re-enabled at the bucket level regardless of the account default.