S3 Public Access Denied: Why Your Public Object URL Still Returns 403

S3 Public Access Denied errors catch engineers off guard when the object ACL is already set to public — the real blocker is often a bucket-level or account-level override silently winning.

TL;DR: S3 Public Access Denied Fix at a Glance

StepWhat to Do
1Check account-level Block Public Access — it overrides everything silently
2Check bucket-level Block Public Access settings and disable the blocking flags
3Verify the bucket policy does not contain an explicit Deny for public reads
4Confirm the object ACL is actually set to public-read (or use a bucket policy instead)
5Test the public URL and validate with a signed URL to isolate the layer

Why S3 Public Access Denied Happens Even After Setting an Object Public

The direct answer: setting an object ACL to public-read is necessary but not sufficient. AWS evaluates public access through a layered permission model — account-level Block Public Access (BPA) settings, bucket-level BPA settings, bucket policies, and object ACLs are all evaluated in sequence. Any blocking layer upstream will deny the request regardless of what the object ACL says.

The root cause in most cases is that one of the BPA flags — either at the account or bucket level — is set to block public ACLs or public policies. The fix is to identify which layer is blocking and address it explicitly.

Think of S3 access like a building with a security desk (account BPA), a floor receptionist (bucket BPA), a room lock (bucket policy), and a guest pass (object ACL). The guest pass is irrelevant if the gate is locked.

The Layered Permission Model Behind the 403

Before diving into the fix, understanding the evaluation order prevents you from chasing the wrong layer. AWS evaluates S3 public access requests in this sequence:

graph LR A[Anonymous GET Request] --> B{Account BPA Enabled?} B -- Yes --> DENY1[403 Access Denied] B -- No --> C{Bucket BPA Enabled?} C -- IgnorePublicAcls=true --> DENY2[ACL Ignored / 403] C -- RestrictPublicBuckets=true --> DENY3[403 Access Denied] C -- No blocking flags --> D{Bucket Policy Explicit Deny?} D -- Yes --> DENY4[403 Access Denied] D -- No --> E{Object ACL public-read?} E -- Yes --> ALLOW[200 OK] E -- No --> DENY5[403 Access Denied]
  1. Account-level BPA: If enabled, it overrides all bucket and object settings. No bucket in the account can serve public content.
  2. Bucket-level BPA: Four independent flags control whether public ACLs and public policies are blocked or ignored at the bucket scope.
  3. Bucket Policy: An explicit Deny in the bucket policy overrides any Allow — including object ACLs.
  4. Object ACL: Only evaluated if all upstream layers permit the request. A public-read ACL grants anonymous GET access at this layer.

Most engineers jump straight to Step 4 and stop there. That's the trap.

Step 1: Check Account-Level Block Public Access First

Check account-level BPA first — bucket-level output gives no indication that account settings are overriding it, so this mistake compounds silently across every fix attempt.

aws s3control get-public-access-block \
  --account-id 123456789012

If any of the four flags (BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets) are true, public access is blocked at the account level. To allow public access for specific buckets, you must first disable the relevant account-level flags:

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

Important: Disabling account-level BPA affects all buckets in the account. In production, prefer leaving account-level BPA enabled and using CloudFront with an Origin Access Control (OAC) instead of direct public S3 URLs.

Step 2: Check Bucket-Level Block Public Access for S3 Public Access Denied

Even with account-level BPA cleared, each bucket has its own independent BPA configuration. Verify it:

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

The four flags and what they do:

FlagEffect When True
BlockPublicAclsRejects PUT requests that grant public ACL permissions
IgnorePublicAclsIgnores all public ACLs on the bucket and its objects — this is the silent killer
BlockPublicPolicyRejects bucket policies that grant public access
RestrictPublicBucketsRestricts access to the bucket to AWS service principals and authorized users only

IgnorePublicAcls is the flag engineers miss most often. You can successfully set an object to public-read — the PUT succeeds, the ACL is stored — but at request time, S3 ignores it entirely. The object appears public in the console but returns 403 on every GET.

The ACL is set. The flag just makes S3 pretend it isn't there.

To disable bucket-level BPA:

aws s3api delete-public-access-block \
  --bucket your-bucket-name

Step 3: Verify No Explicit Deny in the Bucket Policy

An explicit Deny in a bucket policy overrides any Allow — including a valid public-read ACL. Check the current policy:

aws s3api get-bucket-policy \
  --bucket your-bucket-name

Scan the output for any Effect: Deny statements with a Principal: "*" or conditions that would match anonymous requests. If the bucket has no policy, this command returns a NoSuchBucketPolicy error — that is expected and not the issue.

For public static hosting, a bucket policy granting s3:GetObject to all principals is the recommended approach over object ACLs:

🔽 Example: Bucket Policy for Public Read Access
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}

Step 4: Confirm the Object ACL Is Actually Set to public-read

Engineers often assume the ACL was applied because the console showed no error. Verify it directly:

aws s3api get-object-acl \
  --bucket your-bucket-name \
  --key path/to/your-image.jpg

Look for a grant with Grantee.URI equal to http://acs.amazonaws.com/groups/global/AllUsers and Permission of READ. If that grant is absent, the object ACL was never successfully applied — likely because BlockPublicAcls was enabled at the time of the PUT and silently rejected it.

To set it explicitly:

aws s3api put-object-acl \
  --bucket your-bucket-name \
  --key path/to/your-image.jpg \
  --acl public-read

Note: As of April 2023, new AWS accounts have ACLs disabled by default on new buckets (Object Ownership set to BucketOwnerEnforced). In that mode, ACLs are disabled entirely and put-object-acl will return an error. Use a bucket policy for public access instead.

Step 5: Test and Isolate the Blocking Layer

After making changes, test the public URL directly. If it still returns 403, use a presigned URL to isolate whether the issue is public access configuration or object-level permissions:

aws s3 presign s3://your-bucket-name/path/to/your-image.jpg \
  --expires-in 300

If the presigned URL works but the public URL does not, the object exists and your IAM credentials can access it — the blocker is specifically in the public access layer (BPA flags or bucket policy). If the presigned URL also fails, the object key, bucket name, or region endpoint may be incorrect.

graph LR START[Public URL returns 403] --> TRY{Try Presigned URL} TRY -- Also fails --> BADKEY[Wrong key / region / IAM issue] TRY -- Works --> BPA[BPA or bucket policy blocking] BPA --> S1[Check account BPA] S1 --> S2[Check bucket BPA flags] S2 --> S3[Check bucket policy for Deny] S3 --> S4[Re-test public URL]
  1. Public URL returns 403: Start the diagnostic at Step 1 (account BPA).
  2. Presigned URL works, public URL fails: Confirms the object is accessible — the block is in the public access configuration.
  3. Both URLs fail: The object key or bucket endpoint is wrong, or the IAM identity used for presigning lacks s3:GetObject.
  4. Public URL works after BPA changes: Confirm and document which flag was the blocker for your runbook.

Production Gotcha: The Console Shows Public, But Requests Still Fail

Here is what actually happens in production: an engineer uploads an image, clicks "Make Public" in the S3 console, sees the ACL updated successfully, copies the object URL, and hits 403. They check the object — it shows public-read. They check the bucket policy — no explicit Deny. Everything looks correct.

What they are not seeing is that IgnorePublicAcls is set to true at the bucket level. The console does not surface this flag inline when you view an object. The ACL is stored and displayed correctly — S3 just ignores it at request evaluation time. The only place this is visible is the bucket's "Permissions" tab under "Block public access settings."

The fix is always the same: check BPA flags before touching ACLs or policies.

When to Use CloudFront Instead of Direct Public S3 URLs

For production workloads, serving objects directly from a public S3 bucket is rarely the right architecture. CloudFront with an Origin Access Control (OAC) lets you keep the bucket fully private — no public ACLs, no public bucket policy — while still serving content publicly through the CDN layer. This eliminates the entire BPA problem space and adds caching, HTTPS enforcement, and geo-restriction as operational benefits.

Reserve direct public S3 URLs for development, quick prototyping, or static website hosting where CDN overhead is not justified.

Key Terms

TermDefinition
Block Public Access (BPA)A set of four account-level and bucket-level flags that override ACLs and bucket policies to prevent public access to S3 resources
Object ACLAn access control list attached to an individual S3 object that can grant read permissions to all users (public-read)
IgnorePublicAclsA BPA flag that causes S3 to disregard all public ACL grants at request evaluation time, even if the ACL is stored correctly
Origin Access Control (OAC)A CloudFront mechanism that allows a private S3 bucket to serve content through CloudFront without requiring public bucket access
Presigned URLA time-limited URL generated using AWS credentials that grants temporary access to a private S3 object without changing its permissions

For authoritative reference, see the AWS S3 Block Public Access documentation and the S3 ACL overview. If you are setting up CloudFront with a private bucket, review the OAC configuration guide.

Related Posts

Comments

Popular posts from this blog

EC2 No Internet Access in Custom VPC: Fix Internet Gateway and Route Table

EC2 SSH Connection Timeout: Which Security Group Rules to Check

Difference Between IAM User and IAM Role: Which One Should Your EC2 Use?