Back to blog
AWSBest PracticesCloud SecurityOperations & ComplianceStorage

S3 ACL-Based Public Read Not Blocked: Closing the Gap Policy Controls Miss

Your S3 bucket policy blocks public access, but ACL controls are off. Learn why public-read ACLs still leak data and how to enable all four Block Public Access flags.

TL;DR

This check flags S3 buckets that block public access through bucket policies but leave ACL-based public access open because BlockPublicAcls or IgnorePublicAcls is disabled. A public-read ACL on a single object can still expose data. Enable all four Block Public Access settings on the bucket (or account-wide) to close the gap.

Most teams remember to lock down S3 bucket policies. Far fewer remember that ACLs are a completely separate permission system that can grant public read access on their own, regardless of how tight your policy is. This check exists for exactly that blind spot: a bucket where policy-based public access is blocked, but the ACL-based controls are missing, leaving the door open for a publicly readable ACL to take effect.


What this check detects

S3 has two independent ways to grant access to a bucket and its objects:

  • Bucket and IAM policies — the modern, JSON-based way to control access.
  • Access Control Lists (ACLs) — a legacy mechanism that predates IAM, attached to the bucket or to individual objects.

S3 Block Public Access (BPA) is the safety net that overrides both. It has four independent toggles:

  • BlockPublicAcls — rejects new requests that set a public ACL.
  • IgnorePublicAcls — ignores any public ACLs that already exist.
  • BlockPublicPolicy — rejects bucket policies that grant public access.
  • RestrictPublicBuckets — restricts access to buckets already exposed by policy.

This check fires when the two policy-related settings (BlockPublicPolicy and RestrictPublicBuckets) are enabled, but one or both ACL-related settings (BlockPublicAcls, IgnorePublicAcls) are not. In that state, a public ACL on the bucket or on any object inside it will still grant anonymous read access. Your policy looks safe, but the ACL path is wide open.

Note: ACLs and policies are evaluated separately. A bucket policy that denies public access does not neutralize a public-read ACL on an object. They are two doors, and Block Public Access is the lock that covers both. Disabling half of it leaves one door usable.


Why it matters

ACL-based exposure is one of the oldest and most common causes of S3 data leaks, and it is easy to miss because it does not show up in your bucket policy. The classic failure mode looks like this:

  1. Someone uploads an object and explicitly sets --acl public-read to make a single file accessible (maybe a logo, an export, a "temporary" share).
  2. Without IgnorePublicAcls, that object is now anonymously readable, even though the bucket policy blocks public access.
  3. The object URL gets indexed, scraped, or guessed, and whatever is in that bucket leaks.

Because object ACLs can be set per object, you can end up with one publicly readable object sitting in an otherwise private bucket. Audits that only review the bucket policy will pass it. Attackers who run automated S3 enumeration tools will not miss it.

Warning: Tools like S3 enumeration scanners specifically probe for objects with public-read ACLs because they slip past policy-only reviews. A single misconfigured upload script that adds public-read by default can expose every new object until someone notices.

The business impact is the usual S3 leak story: customer PII, internal backups, application secrets, or logs exposed to the internet. Beyond the breach itself, ACL-based exposure complicates compliance. PCI DSS, HIPAA, SOC 2, and most internal data classification policies expect public access controls to be comprehensive, not partial. A bucket that blocks policy access but allows ACL access is a half-measure that an auditor will rightly flag.


How to fix it

The fix is to enable all four Block Public Access settings. There is almost never a good reason to leave the ACL toggles off, and AWS now disables ACLs entirely on new buckets by default for the same reason.

1. Inspect the current state

aws s3api get-public-access-block \
  --bucket my-bucket-name

If the response shows "BlockPublicAcls": false or "IgnorePublicAcls": false, that is what this check is catching.

2. Check for existing public ACLs before you change anything

Before enabling IgnorePublicAcls, find out whether anything currently depends on a public ACL so you do not break a live workflow without knowing.

# Check the bucket-level ACL
aws s3api get-bucket-acl --bucket my-bucket-name

# Spot-check an object ACL
aws s3api get-object-acl --bucket my-bucket-name --key path/to/object

Look for grants to http://acs.amazonaws.com/groups/global/AllUsers (anonymous) or AuthenticatedUsers (any AWS account). Those are the grants that ACL blocking will neutralize.

Danger: If a real, intended public workflow relies on a public-read ACL (for example, a static asset bucket), enabling IgnorePublicAcls will immediately cut off that access and can break the front end serving those files. Confirm the bucket is not intentionally public before applying the fix, and migrate intentional public access to a proper distribution method like CloudFront with Origin Access Control instead.

3. Enable all four settings

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

Verify the change applied:

aws s3api get-public-access-block --bucket my-bucket-name

You should see all four fields set to true.

Console steps

  1. Open the S3 console and select the bucket.
  2. Go to the Permissions tab.
  3. Under Block public access (bucket settings), choose Edit.
  4. Check Block public access to buckets and objects granted through new access control lists (ACLs) and ...through any access control lists (ACLs).
  5. Confirm all four boxes are checked, then Save changes and type confirm.

Fix it for the whole account

If you want a single guarantee that covers every current and future bucket, set Block Public Access at the account level. This overrides bucket-level settings and is the strongest option for organizations that have no public buckets at all.

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

Tip: Account-level BPA is the cleanest control if you genuinely have no public buckets. It removes the need to remember the setting per bucket, and new buckets inherit the protection automatically. If you do need a public bucket later, you can serve it through CloudFront instead of opening S3 directly.

Disable ACLs entirely

For a more permanent fix, set the bucket's object ownership to Bucket owner enforced, which disables ACLs completely. After this, ACLs are no longer evaluated at all and only policies grant access.

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

This is the direction AWS itself recommends, and it makes the entire class of ACL-based exposure impossible.


How to prevent it from happening again

One-off remediation does not stick. Bake the control into how buckets get created so a missing ACL block setting never ships.

Terraform

Always pair an S3 bucket with an explicit aws_s3_bucket_public_access_block resource that sets all four flags, and enforce bucket-owner-enforced ownership.

resource "aws_s3_bucket" "data" {
  bucket = "my-bucket-name"
}

resource "aws_s3_bucket_ownership_controls" "data" {
  bucket = aws_s3_bucket.data.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "data" {
  bucket = aws_s3_bucket.data.id

  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy      = true
  restrict_public_buckets = true
}

Gate it in CI/CD

Catch a missing or partial public access block before it reaches AWS. A policy-as-code check with Checkov, tfsec, or OPA/Conftest fails the pipeline when any of the four flags is false.

# Checkov flags missing or incomplete public access block on S3
checkov -d ./infra --check CKV_AWS_56,CKV_AWS_53,CKV_AWS_54,CKV_AWS_55

Tip: Use an AWS Service Control Policy (SCP) to deny s3:PutPublicAccessBlock calls that would weaken the settings, and deny s3:PutBucketAcl with public grants. That way even a privileged engineer or a compromised pipeline cannot turn the ACL block off. Combine this with account-level BPA for defense in depth.

Continuous detection

For buckets you do not control through IaC, continuous monitoring is what closes the loop. Lensix runs the s3_public_read_access module across your accounts and flags any bucket where the ACL block settings are missing, so drift gets surfaced the moment it appears rather than at the next audit.


Best practices

  • Treat Block Public Access as all-or-nothing. Enabling two of four flags creates a false sense of safety. Set all four unless you have a documented, reviewed reason not to.
  • Disable ACLs by default. Use Bucket owner enforced ownership on new buckets. ACLs are legacy, and removing them eliminates an entire category of misconfiguration.
  • Never serve public content directly from S3. Put CloudFront with Origin Access Control in front of any bucket that needs public reach, and keep the bucket itself private.
  • Apply account-level BPA wherever possible. It is the highest-leverage single setting for preventing accidental S3 exposure.
  • Audit upload tooling. Search your codebase and scripts for --acl public-read and ACL: 'public-read'. These are the lines that quietly create public objects.
  • Encrypt and version sensitive buckets too. Public access control is one layer. Default encryption, versioning, and access logging round out a bucket that holds anything you care about.

The takeaway is simple: a locked bucket policy is only half the job. Until the ACL block settings are on, a single public-read ACL on one object can undo all of it. Turn on all four flags, disable ACLs where you can, and enforce it in code so it never drifts back.