If you've ever pushed a perfectly successful build to S3, wired it up to CloudFront, hit the URL - and gotten a flat "403 Access Denied" - this post is for you. It's one of those errors that looks like a permission problem, send you down an IAM rabbit role and turns out to be something much simpler.
Here's the exact debugging path I took recently while setting up a Bamboo CI/CD pipeline deploying an Angular front-end to S3 + CloudFront.
The setup:
The pipeline was straightforward on paper:
- Bamboo Specs build the Angular app
- Build artifacts sync to S3 bucket
- CloudFront serves the bucket as a static site via an Origin Access Control (OAC)
The build succeeded.The S3 sync succeeded. aws s3 ls showed the files sitting right there in the bucket. And yet, hitting the CloudFront distribution URL returned:
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied </Message>
</Error>
Where I looked first(why was it a dead end)
The instinct with AccessDenied is almost always IAM: bucket policy,OAC permissions, CloudFront's ability to read from the origin. I checked all three:
Bucket policy explicitly allowed s3:GetObject for the CloudFront OAC principal
The OAC was correctly attached to the distribution's origin
The distribution's origin path was pointing at the bucket
Everything looked correct. This is the trap - When the permission are actually fine, you can burn hours re-verifying policies that were never the problem.
The actual root cause: bucket root structure
The real issue was upstream of permissions entirely: where the files landed inside the bucket didn't match where CloudFront was told to look.
The build tool (in this case, an Angular esbuild-based builder) had output the production bundle into a nested folder- not directly at the bucket root, but one level down. CloudFront's origin path, meanwhile was configured to serve from the bucket root. So when a request came in for index.html, CloudFront looked in a location where nothing existed.
S3 and CloudFront don't distinguish that scenario from a genuine permissions failure - a missing object under an OAC-protected origin returns the same generic Acces Denied, not a 404. Tha's what made this so easy to misdiagnose:the error message pointed at IAM, but the actual fault was a path mismatch.
How to confirm it's a structure issue , not a permission issue
Before you touch a single IAM policy, run this quick check:
aws s3 ls s3://your-bucket-name/ --recursive
Look closely at there your index.html and asset files actually sit. If your CloudFront origin path is set to the bucket root (empty or /) but your files are sitting under a subfolder like /browser/ or /dist/, you've found yur answer - no policy debugging required.
You can also confirm from the CloudFront side: check the Origin Path field on the distribution's origin settings and compare it directly against the actual object keys in the bucket listing.
The Fix:
Two options, depending on what you control:
Option A - Fix the build output path. If your build tools is dumping files into a nested folder, adjust the build config so files land exactly where the origin expects them - typically the bucket root.
Option B - Fix the CloudFront origin path. If restructuring the build isn't practical, point the origin path at the correct subfolder instead:
Origin path: /browser
Either way, the goal is the same: make the object key CloudFront requests match an object key that actually exists in the bucket.
Takeaways
Access DEnied on a statuc site behind CloudFRont + OAC is not always a permission bug - a missing object under a protected origin returns the same error as a genuinely blocked one.
Before touching IAM, verify with aws s3 ls --recursive that your files are actually where your origin path expects them.
Build tool changes (a new bundler , an updated framework version, a build config tweak) can silently shift output paths - Worth chcking first any time this error shows up right after a build tooling change.
Document your bucket's expected root structure somewhere visible in the pipeline - it saves the next person (possibly future you) from re-walking this same IAM detour.
Have you hit a CloudFront/S3 error that turned out to be something completely different from what it looked like? I'd love to hear about it - drop a comment below

Top comments (0)