AWS S3 bucket permissions error: data exposure from public access
You get AccessDenied when an S3 bucket is set to private but an object's ACL allows public read. Here's how to lock it down.
You set up an S3 bucket to host a static website. You set the bucket policy to deny public access. But when you try to view an object in the browser, you get a 403 AccessDenied error. Or worse — you check the bucket's permissions and they look fine, but someone outside your account still gets data. This usually happens when you've got a mix of bucket policies and object ACLs that don't match. I've seen this on teams that inherited old buckets. The bucket policy blocks public access, but individual objects have ACLs set to 'public-read'. That override catches you off guard.
Why this happens
An S3 bucket has two permission layers: the bucket policy (which controls access to the bucket and its objects as a whole) and object ACLs (which control access to specific objects). They don't always align. If the bucket policy says 'Deny all public access' but an object's ACL says 'Allow public read', the Deny wins — but only if the bucket policy explicitly targets the object. If the bucket policy only blocks s3:GetBucketPolicy or similar, the object ACL might let anyone read that single file. You get a 403 because S3 checks the bucket policy first, then the object ACL. If the bucket policy allows the request but the ACL blocks it, you get AccessDenied. If the policy blocks it entirely, the ACL doesn't matter — but that's not what's happening here.
The real root cause is that someone — maybe a script, maybe a manual console click — set an object ACL to public-read without checking the bucket's overall policy. Or they set the bucket policy to deny public access after the objects were already public. S3 doesn't retroactively remove ACLs. So you've got a split permission scenario.
The fix — step by step
This fix assumes you have AWS Console access and the bucket name. If you're using CLI, the commands are the same but you'll need to substitute values.
- Open the S3 console. Go to your bucket. Click the 'Permissions' tab. Look at the 'Block public access (bucket settings)' section. Make sure all four boxes are checked. If they're not, click 'Edit' and check them all. Then click 'Save changes'. This blocks all public access at the bucket level, regardless of object ACLs. After saving, you should see a green banner saying 'Successfully edited block public access settings'.
- Check the bucket policy. Scroll down to 'Bucket policy'. If there's a policy that allows public access (like
"Principal": "*"with"Effect": "Allow"ons3:GetObject), delete that policy or replace it with a Deny. A safe catch-all policy is:
Replace{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyPublicRead", "Effect": "Deny", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::your-bucket-name/*", "Condition": { "StringNotEquals": { "aws:SourceVpce": "vpce-xxxxxxxx" } } } ] }your-bucket-namewith your actual bucket. This denies all public GetObject requests unless they come from a specific VPC endpoint (which you can remove if you don't need it). After clicking 'Save', you should see 'Policy saved successfully'. If you get a policy error, double-check the ARN. - Clear object ACLs. Go to the 'Objects' tab. Select all objects (click the checkbox at the top). Click 'Actions' → 'Edit public access'. Choose 'Block public access'. Then click 'Save changes'. This removes any
public-readorpublic-read-writeACL on every selected object. For buckets with thousands of objects, use the CLI instead:
This makes every object private. Run it from a terminal with AWS credentials. It'll take a few minutes. After it finishes, test one object by trying to open its URL in a private browser window. You should see a 403 — which is what you want if you don't want public access.aws s3api list-objects --bucket your-bucket-name --query 'Contents[].Key' --output text | xargs -I {} aws s3api put-object-acl --bucket your-bucket-name --key {} --acl private - Verify with a test object. Upload a new file. Set its ACL to 'private' manually (or let it inherit bucket settings). Try to access it via its direct URL (like
https://your-bucket.s3.amazonaws.com/test.txt). You should get an AccessDenied error. If you get the file contents, something is still open. Go back to step 1 and check the 'Block public access' settings again.
What to check if it still fails
If you're still getting AccessDenied after following these steps, look at these three things:
- Bucket policy syntax. One missing character can break the whole policy. Copy your policy into the AWS Policy Validator in the console. It'll flag errors.
- Object ACLs on nested folders. S3 doesn't have real folders — it's all flat key names. But the console shows them as folders. If you have a 'folder/', its ACL might be public even if the objects inside are private. Check the ACL on any 'folder' objects (they're objects with a trailing slash). Repeat step 3 for those.
- CloudFront or CDN caching. If you're serving via CloudFront, it might cache the old object. Wait a few minutes or invalidate the CloudFront cache. Go to CloudFront console, select your distribution, click 'Invalidations', then 'Create invalidation', and enter
/*. This clears everything. After the invalidation completes (usually 5-10 minutes), test again. - Third-party tools or scripts. If you use tools like Terraform or CloudFormation to manage the bucket, check their definitions. They might be reapplying public ACLs on every deploy. Add
acl = "private"andblock_public_acls = truein your Terraform resource block. For CloudFormation, setPublicAccessBlockConfigurationto block all four types.
One last thing — if you're absolutely sure the bucket is locked down but you still need to give access to specific users or roles, don't touch the bucket policy. Use IAM policies attached to those users or roles instead. That's cleaner and easier to audit. I've seen too many teams get burned by a bucket policy that accidentally opens the door because someone added "Principal": "*" thinking it meant 'only authenticated users'. It doesn't. It means everyone on the internet.
Was this solution helpful?