S3 Static Website 403 Forbidden: Every Fix, in Order
A 403 Forbidden from an S3 static website almost always means the file is there but the public isn't allowed to read it. Fix it in this order: open the bucket's website endpoint over http:// (not https://), turn off Block Public Access on the bucket and check the account and organization above it, add a bucket policy that gives everyone s3:GetObject on arn:aws:s3:::your-bucket/*, and make sure you own the objects and nothing denies them. The twist: a file that doesn't exist gets a 404, so the 403 is oddly good news.
Jake's booking page went dark at 9 a.m. on a Saturday, right when his first walk-in customer asked whether the screen repair slots were still open. The page is a plain folder of HTML files in an S3 bucket he named after his cat. It had worked for two years. That morning it showed a white page with 403 Forbidden, Code: AccessDenied, and nothing else, and Jake did what most of us do: he changed three settings at once and made it worse.
This post is the calm version of that morning. It goes from the cheapest check to the most drastic one, gives you both the console click path and the CLI command for each, and names the exact error text you'll see when a step is the culprit. Ethan, Jake's mentor, walks through it with him.
What a 403 on an S3 website endpoint actually means
Start with the rule that makes the rest of the checklist easy. On a bucket's website endpoint, a request for an object that doesn't exist returns 404 (Not Found). A request for an object that does exist, where nobody has granted read permission, returns 403 (Access Denied). So the white page is telling you, indirectly, that the file is sitting in the bucket. Your job is to find which layer of permission is saying no.
A website endpoint is the address S3 gives a bucket once you switch on static website hosting. It's built for browsers, which means it can serve a default page, send redirects and return an HTML error page. That's different from the REST API endpoint, the address programs like the AWS CLI use to talk to S3. People mix the two up constantly, and the mix-up alone explains a big share of the 403s in the wild.
| REST API endpoint | Website endpoint | |
|---|---|---|
| Access control | Supports both public and private content | Supports only publicly readable content |
| Error responses | XML-formatted error | HTML document |
| Redirects | Not applicable | Object-level and bucket-level redirects |
| Requests allowed | All bucket and object operations | GET and HEAD on objects only |
| HTTPS | Yes (HTTP and HTTPS) | No. Website endpoints don't support HTTPS or access points |
Ethan: "Think of the REST endpoint as the staff entrance and the website endpoint as the front door. The front door has one rule: if a stranger can't walk in and read the file, it stays shut. It never asks who you are."
Two other answers show up on the same endpoint and point at different problems. A 400 Malformed Request means you reached the bucket through the wrong regional endpoint, so it's a typo in the Region part of the URL. A 404 with the code NoSuchWebsiteConfiguration means the bucket exists but website hosting isn't switched on. Neither is a permissions problem, and both are handy: if you're staring at a 403, hosting is on and the address is probably right.
One side effect is worth knowing before you fix anything. Because a missing file gives 404 and an unreadable file gives 403, anyone can probe your site for file names by watching which code comes back. If that bothers you, the honest answer is that a bucket with website hosting turned on will always behave this way. The way around it is to serve the site through CloudFront with a private bucket, which is covered in the private-bucket section further down.
Step 0: check the address before you touch a setting
This costs nothing and rules out a whole family of false alarms. Find the real website endpoint: in the console, open the bucket, choose Properties, and scroll to the bottom to Static website hosting. The Bucket website endpoint is listed there. Copy it rather than building it by hand.
Depending on the Region, the endpoint comes in one of two shapes. Older Regions use the dash form, http://bucket-name.s3-website-Region.amazonaws.com. Newer ones use the dot form, http://bucket-name.s3-website.Region.amazonaws.com. For example, US East (N. Virginia) is s3-website-us-east-1.amazonaws.com and US East (Ohio) is s3-website.us-east-2.amazonaws.com. Notice both say http. Neither will answer on https.
- Address starts with
https://. Website endpoints don't do HTTPS. Typehttp://explicitly, or put CloudFront or Amplify Hosting in front of the bucket if you need HTTPS. - Address looks like
https://bucket.s3.Region.amazonaws.com/.... That's the REST endpoint. It serves public and private objects, so a 403 there returns an XML error, and it doesn't apply the index document or redirects that make a website a website. - Wrong Region in the URL. That produces
400 Malformed Request, not a 403. Copy the endpoint from the Properties tab. - POST or PUT to the endpoint. A website endpoint accepts only GET and HEAD on objects, so a form that posts to it won't work no matter how open the bucket is.
- No bucket name at all. Requesting the bare
s3-website.Region.amazonaws.comaddress redirects you (301) to the S3 product page, which can look like a broken site.
A quick way to learn which door you're at: load the same object on both. If the REST URL for a file returns the file and the website URL returns 403, you're looking at something website-specific such as a missing index document. If both refuse, the object isn't public, and every check below applies.
Jake: "So I can just use the long https address that works in my browser? It loads my logo fine."
Ethan: "It loads your logo because that's the REST endpoint and that one object happens to be readable. It's not your website. The moment you ask it for the folder page, it has no idea what an index document is."
The checklist at a glance, cheapest check first
Work top to bottom. Each row is a place a 403 can come from, ordered by how little effort it takes to look and how often it's the culprit for a brand-new site. The sections after this table give the console route and the CLI route for each row.
| # | Check | Where to look | What it looks like when it's the cause |
|---|---|---|---|
| 1 | Website hosting on, index document set | Bucket → Properties → Static website hosting | Hosting off: 404 with NoSuchWebsiteConfiguration. Index document missing: the root URL (/) fails but /index.html loads |
| 2 | Block Public Access (bucket, account, organization) | Bucket → Permissions; S3 → Block Public Access settings for this account; Organizations | Bucket policy won't save, or saves and still 403s |
| 3 | Bucket policy grants s3:GetObject to everyone | Bucket → Permissions → Bucket policy | Policy has invalid resource, or no policy at all |
| 4 | Who owns the object | Object → Permissions; aws s3api list-objects | Files uploaded by another account still 403 while yours load |
| 5 | An explicit Deny somewhere | Bucket policy, service control policies, VPC endpoint policy | Public Allow exists, but a Deny with a condition beats it |
| 6 | KMS-encrypted objects | Object → Properties → Server-side encryption; head-object | ServerSideEncryption shows aws:kms |
| 7 | Requester Pays | Bucket → Properties → Requester pays | Every request 403s regardless of policy |
| 8 | The object isn't where you think | aws s3api head-object; server access logs | Wrong case, wrong folder, or a stray leading slash |
Ethan: "A brand-new bucket usually goes wrong at row 2 or row 3. An older bucket that several people have touched usually goes wrong at row 4 or row 5. Don't skip ahead to KMS just because it sounds clever."
Check 1: static website hosting is on and the index document is set
Switching on hosting is what creates the website endpoint in the first place. If hosting is off, the endpoint answers 404 with NoSuchWebsiteConfiguration, not 403. So a 403 usually means hosting is already on and the culprit is the other half: the index document, the file S3 hands back when someone asks for the site's root address ending in /. Without one, S3 doesn't treat the bare / as a valid object name, and the request fails even though /index.html works when typed out in full.
Console route: S3 → your bucket → Properties → Static website hosting → Edit → choose Enable, type index.html as the Index document, optionally set an Error document, and Save changes. Type the name only. index.html, not /index.html.
CLI route: put a small JSON file next to you and apply it.
{
"IndexDocument": {
"Suffix": "index.html"
},
"ErrorDocument": {
"Key": "error.html"
}
}
aws s3api put-bucket-website --bucket your-bucket --website-configuration file://website.json
That command needs the s3:PutBucketWebsite permission, and by default only the bucket owner has it. If you're in a shared account and get an access error running it, that's a different 403: yours, not your visitors'.
Two details trip people up. Object names in S3 are case-sensitive, so Index.html and index.html are different files, and the name you type into the index document field has to match the object exactly. And if you use CloudFront in front of the bucket, its own Default root object setting does the same job for the root path, with the same rule: index.html, no leading slash. The error document is just as fussy: its name is case-sensitive and has to match the uploaded file exactly, and S3 serves it only for 4XX errors.
Jake: "My index.html is definitely in the bucket. I can see it in the console."
Ethan: "Then open its Object URL and look at the case of every letter, and look at whether it's inside a folder. Jake, last month you uploaded the Website folder itself instead of what's inside it. Your file was at Website/index.html. Nobody in the world was going to find it there."
Check 2: Block Public Access at the bucket, account and organization level
Block Public Access is the safety catch S3 puts over every new bucket. It has four separate switches: BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy and RestrictPublicBuckets. Between them they stop new public ACLs, ignore existing ones, refuse to save a bucket policy that allows public access, and restrict a bucket with a public policy to AWS service principals and authorized users in the bucket owner's account. In plain terms: while they're on, even a perfect public-read policy does nothing.
The rule that catches people: S3 applies the most restrictive combination of the bucket-level and account-level settings. You can clear every box on the bucket, and if the account has Block Public Access on, the bucket stays locked. Since November 26, 2025 there's a third layer above both of those. An organization in AWS Organizations can attach an S3 policy at the root, at an organizational unit (OU), or to specific accounts, and that policy propagates to the accounts under it. New member accounts inherit it automatically.
🕐 What changed, and when
- Before April 2023: a bucket created by the CLI, SDK or CloudFormation could start out without Block Public Access, while console-created buckets already had it.
- April 2023: every new bucket, however created, started with Block Public Access on and ACLs disabled. Existing buckets weren't changed. That's why an old bucket and a new bucket can behave differently on the same day.
- November 26, 2025: Block Public Access can be enforced for a whole organization from AWS Organizations, at no extra charge. Accounts under such a policy inherit it.
- What that means for you: a 403 on a new bucket is nearly always this switch. A 403 in a company account that used to work is worth asking your cloud admin about.
Now clear the layers in order, starting at the bucket.
- Open the S3 console and choose your bucket.
- Choose Permissions.
- Under Block public access (bucket settings), choose Edit.
- Clear Block all public access and choose Save changes.
- If you see a note under that section saying account-level settings are on, go to S3 → Block Public Access settings for this account in the left menu, choose Edit, and clear it there too.
- If the account-level box is grayed out or reverts, your account sits under an organization policy. The change has to happen in the AWS Organizations console, by whoever manages the organization.
CLI route for the bucket. The bucket-level commands live under s3api:
aws s3api put-public-access-block \
--bucket your-bucket \
--public-access-block-configuration \
BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false
If you serve the site with a bucket policy, which is what this post assumes, you only need BlockPublicPolicy and RestrictPublicBuckets set to false. The two ACL switches only matter when ACLs are in play, so you can leave them true as a little extra protection. The console button clears all four because it can't know how you plan to grant access. If you upload from another account and rely on ACLs (Check 4), clear all four.
CLI route for the account. Account-level settings live under s3control, and the command asks for your 12-digit account ID:
aws s3control get-public-access-block --account-id 111122223333
What comes back is the effective account-level configuration, which can include settings inherited from an organization-level policy. If you see true for BlockPublicPolicy and RestrictPublicBuckets and you never set them, look upward. To change them yourself (assuming no organization policy stands above you) use aws s3control put-public-access-block with the same four names. You need s3:PutBucketPublicAccessBlock for the bucket and s3:PutAccountPublicAccessBlock for the account.
To ask S3 whether it now considers a bucket public, use:
aws s3api get-bucket-policy-status --bucket your-bucket
The output has an IsPublic value. true means S3 sees a public policy. false after you added your policy means something above is still overriding it. Note that this reports on the bucket policy, not on whether every object is readable, so it's a hint, not a verdict.
🙋♂️ Jake's Reality Check
"Isn't switching off 'Block all public access' exactly how companies end up in the news?"
The straight answer. Yes, when it's done on a bucket that also holds customer files. The setting is per bucket, so the rule I'd follow is simple: a bucket that hosts a website holds website files and nothing else. Keep Block Public Access on for every other bucket you own, and do the private-bucket route below if you can.
⚠️ What turning this off actually exposes
Once Block Public Access is off and a public policy is attached, anyone on the internet can read every object in that bucket, including files you uploaded there by accident such as a backup, a spreadsheet or a .env file. Look at the object list before you save the policy, and delete anything that isn't part of the site.
Check 3: the bucket policy that lets the public read your files
Clearing Block Public Access only removes the catch. It doesn't grant anything. To make objects readable by strangers you need a bucket policy that grants everyone s3:GetObject, the permission to read an object. Here's the shape:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::your-bucket/*"
]
}
]
}
Read it like a sentence. Effect: Allow says this rule grants something. Principal: * means anyone, signed in or not. Action: s3:GetObject is reading a file. Resource is the ARN, the full unique name AWS gives a thing, and for objects it's the bucket name followed by /*. That trailing /* is the part meaning every object inside the bucket. Leave it off and the statement no longer covers your files, which is the most common typo in this whole topic.
Console route: bucket → Permissions → Bucket policy → Edit, paste the JSON, replace your-bucket with the exact bucket name, and Save changes. CLI route:
aws s3api put-bucket-policy --bucket your-bucket --policy file://policy.json
Two failure messages are worth recognizing:
Policy has invalid resource. The bucket name inside the ARN doesn't match the bucket you're editing. Check the spelling and that you haven't left the placeholder in.- The policy won't save at all. Block Public Access at the bucket or account level is still refusing public policies. Go back to the previous section and look at the account and organization layers.
Two limits are worth writing on a sticky note. First, this policy covers only objects owned by the bucket owner, which is Check 4. Second, don't 'fix' a confusing 404-versus-403 by also granting public s3:ListBucket. That lets anyone list every file name and size in the bucket, including files you never linked to. The site works without it.
Jake: "Ethan, I named my bucket after my cat. Do I put the cat's name in the policy or the website address?"
Ethan: "Both, Jake, and spell it the way you spelled it in the console. Whiskers2019 and whiskers-2019 are two different animals. I've watched grown engineers paste the wrong one and stare at the screen for an hour."
The popular tutorial advice here is to select your files, choose Actions, then Make public using ACL. On a bucket created since April 2023 that button doesn't help, and I'll explain why in the next check.
Check 4: who owns the objects (ACLs, uploads from other accounts)
A bucket policy grants access only to objects that the bucket owner owns. That's usually true and you never think about it. It stops being true when someone in another AWS account uploads files into your bucket while ACLs are enabled: the uploader owns those files, and your public policy simply doesn't reach them. The symptom is distinctive. Some files load and others, uploaded by a partner or an old deployment pipeline, return 403.
S3 Object Ownership is the bucket-level setting that controls this. New buckets use Bucket owner enforced, where ACLs are disabled and the bucket owner owns every object no matter who uploaded it. With that setting, requests to set or update an ACL fail with the error code AccessControlListNotSupported. That's the reason the 'Make public using ACL' advice in older tutorials goes nowhere on a modern bucket.
| Situation | Who owns the file | Does your public bucket policy reach it? | Fix |
|---|---|---|---|
| Everything uploaded from your own account | You | Yes | Nothing to do |
| Another account uploaded, Bucket owner enforced is on | You (ownership is enforced) | Yes | Nothing to do |
| Another account uploaded, ACLs enabled, no ownership change | The uploader | No | Change ownership (below), or have the ACL grant public READ |
Find out who owns what. The canonical ID is the long hexadecimal string S3 uses to identify an account. Get yours, then the object's:
aws s3api list-buckets --query Owner.ID
aws s3api list-objects --bucket your-bucket --prefix index.html
If the two IDs differ, bucket and object have different owners. The console shows the same thing on the Permissions tab of the bucket and of the object.
Fix option A: switch on Bucket owner enforced. This is the cleanest and the one I'd pick.
aws s3api put-bucket-ownership-controls --bucket your-bucket \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
⚠️ What this actually breaks
With Bucket owner enforced on, ACLs on the bucket and its objects are deactivated. If a partner's uploads or an old script relied on an ACL, they stop working. Look at who writes to the bucket before you flip it.
Fix option B: keep ACLs and take ownership object by object. From the uploader's account, read the object's ACL with aws s3api get-object-acl --bucket your-bucket --key object-name. If it lacks bucket-owner-full-control, grant it with aws s3api put-object-acl --bucket your-bucket --key object-name --acl bucket-owner-full-control. Then, from the bucket owner's account, copy the object over itself so the copy is owned by you:
aws s3 cp s3://your-bucket/index.html s3://your-bucket/index.html
Copying an object over itself drops its storage class and its website-redirect-location setting unless you restate them in the copy request. If an object relies on either, add those values to the command.
Fix option C: an ACL granting READ to everyone. For an object that must stay owned by someone else, the owner grants READ to the AllUsers group, the URI http://acs.amazonaws.com/groups/global/AllUsers. This only works when ACLs are enabled, and Block Public Access's two ACL switches, BlockPublicAcls and IgnorePublicAcls, have to be off for it to take effect.
Check 5: an explicit Deny hiding in a policy you forgot about
In S3 permissions, an explicit Deny always overrides an Allow. That means your careful public-read statement can be correct in every detail and still lose to a Deny sitting in the same policy, or in a policy someone else wrote higher up.
Here's the pattern in a bucket policy. It has an Allow for everyone and, further down, a Deny that says 'unless the request comes through this specific VPC endpoint.' A VPC endpoint is a private doorway from inside your network to an AWS service. Public visitors aren't coming through it, so they hit the Deny:
{
"Sid": "Access-to-specific-VPCE-only",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": ["arn:aws:s3:::your-bucket/*"],
"Condition": {
"StringNotEquals": { "aws:sourceVpce": "vpce-1a2b3c4d" }
}
}
Where to look, in order:
- The bucket policy. Open it in the console and scan for
"Effect": "Deny"next tos3:GetObjectors3:*. Remove or narrow anything that blocks public reads. - Conditions the website endpoint can't meet. A Deny that requires a particular VPC endpoint or IP range shuts out the public. So does the common "HTTPS only" statement, a Deny with
"aws:SecureTransport": "false": the website endpoint speaks only HTTP, so every visitor trips it. - Service control policies (SCPs). These are organization-wide guardrails. Someone with access to the organization's management account can check whether an SCP with a Deny for
s3:GetObjectis attached to the organization root, to your OU, or directly to your account. - VPC endpoint policies. These matter only if the request passes through an endpoint. If a Deny appears there for
s3:GetObject, update the endpoint policy.
On the CLI, aws s3api get-bucket-policy --bucket your-bucket --query Policy --output text prints the policy so you can read it in your terminal.
Ethan: "A Deny is the one thing in IAM that never argues and never compromises. When a bucket does everything right and still says no, look for the sentence that starts with Deny."
Check 6: objects encrypted with KMS can't be served to anonymous visitors
This is the check that surprises people who did everything else correctly. AWS Key Management Service (KMS) doesn't support anonymous requests. A bucket can allow public access, but that access doesn't apply to objects encrypted with KMS keys. A website visitor is by definition anonymous, so a KMS-encrypted index.html returns 403 even with a flawless policy.
Console route: open the object, choose Properties, and look at Server-side encryption settings. If it says AWS KMS, you've found it. CLI route:
aws s3api head-object --bucket your-bucket --key index.html
If the output includes "ServerSideEncryption": "aws:kms", that's your cause. Make it plain SSE-S3 (AES-256) instead. From the console, change the object's encryption setting. From the CLI, first look at the bucket's default encryption. If the default is KMS, change that too, or the next upload lands encrypted with KMS again. Then copy the object over itself:
aws s3 cp s3://your-bucket/index.html s3://your-bucket/index.html
The same warning as before applies: that copy resets the storage class and website-redirect-location unless you restate them.
One more layer for later. When you put CloudFront in front of the bucket, the two ways of authorizing CloudFront treat KMS differently. The older origin access identity (OAI) doesn't work with KMS-encrypted objects. The newer origin access control (OAC) does, provided the OAC has permission to use the KMS key. The CloudFront section below shows where that fits.
Check 7: Requester Pays turns every website request into a 403
Requester Pays flips the bill so the person downloading pays the request and transfer costs instead of you. It's meant for sharing big datasets, and it doesn't mix with websites. A bucket with Requester Pays turned on doesn't allow access through the website endpoint, and any request to it receives 403 Access Denied. Anonymous access to a Requester Pays bucket isn't allowed either, and users from other accounts must include the request-payer parameter, which a browser loading a web page can't do.
Check it in the console: bucket → Properties → Requester pays. If it's enabled and the bucket is meant to be a website, turn it off. If someone turned it on deliberately to share data, they need a second bucket for the website.
Jake: "Wait, could a bucket somebody else made be doing this to me?"
Ethan: "If you inherited the bucket from an agency or a former colleague, yes. Read the Properties tab top to bottom on any bucket you didn't create. It takes three minutes and it's how you find the setting nobody told you about."
Check 8: the file isn't where you think, and S3 says 403 anyway
Since a nonexistent file on the website endpoint returns 404, a 403 normally proves the file exists. Normally. The exceptions all involve an S3 request made without s3:ListBucket permission. If the caller can't list the bucket, S3 doesn't reveal whether a missing object exists, and the answer can come back as Access Denied instead of Not Found. You can hit this through CloudFront or the REST endpoint.
So when a 403 makes no sense, prove the object exists:
aws s3api head-object --bucket your-bucket --key path/to/file.html
If it returns metadata, the object is there and you've been looking at a real permission problem. If it fails, the 403 was masking a missing file. Things to look for:
- Case. Keys are case-sensitive.
Contact.htmlis notcontact.html. - Folder prefixes. The console shows folders, but S3 stores a flat list of keys with slashes in them.
Website/index.htmlis notindex.html. - A leading slash in the CloudFront Default root object field. Type
index.html, not/index.html. - What is actually being requested. Turn on server access logging for the bucket and read which key CloudFront or the browser really asks for.
Behind CloudFront: the 403 that comes from how you connected the origin
If the site sits behind a CloudFront distribution and you see 403, settle one question first, because everything else follows from it: is the distribution's origin a website endpoint or a REST API endpoint? Open the CloudFront console, choose the distribution, open the Origins tab, and read the Origin domain. A name like bucket.s3.Region.amazonaws.com is a REST endpoint. A name with s3-website in it is a website endpoint. In the dash-or-dot spelling, either counts.
| Origin type | What the bucket must allow | How CloudFront gets in | Block Public Access |
|---|---|---|---|
| Website endpoint (custom origin) | Public read. A distribution using a website endpoint supports only publicly accessible content | Anonymous requests | Must be off for the bucket |
| Website endpoint plus Referer secret | Public-style Allow on s3:GetObject with an aws:Referer condition | CloudFront adds a Referer header holding a shared secret | Must be off for the bucket |
| REST endpoint plus OAC | A bucket policy for the cloudfront.amazonaws.com service principal, limited to your distribution | Signed requests from CloudFront | Can stay fully on |
| REST endpoint plus OAI (legacy) | A bucket policy for the OAI principal | Signed requests from the OAI | Can stay on; KMS-encrypted objects are unsupported |
The pairing I'd steer you away from is OAC or OAI on a website endpoint. OAC exists to keep the bucket private, and a website endpoint serves only public content, so the two goals fight each other and you end up chasing a 403 that no policy edit fixes. Pick one lane.
If you go the REST endpoint plus OAC route, the bucket policy looks like this. Replace the account ID and distribution ID with your own:
{
"Version": "2012-10-17",
"Statement": {
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
}
}
}
}
If you keep the website endpoint and use the Referer approach, the token in the bucket policy must match the origin custom header on the distribution, character for character:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Allow get requests originating from my CloudFront with referer header",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket/*",
"Condition": {
"StringLike": { "aws:Referer": "MY_SECRET_TOKEN_CONFIGURED_ON_CLOUDFRONT_ORIGIN_CUSTOM_HEADER" }
}
}
]
}
The header name on the CloudFront origin must be Referer. If you use a Deny based on the referer, there also has to be an Allow that grants access; a Deny alone grants nothing. A word of caution: this approach keeps the bucket readable to anyone who learns the token. It's fine as a stopgap, and I wouldn't call it a security design.
A few other CloudFront-specific causes are worth a glance:
- No default root object. Set the Default root object on the distribution, or configure the index document on the S3 website endpoint. Requests for
/fail without one. - KMS-encrypted objects with an OAI. Unsupported. Move to OAC and grant it permission on the key, or re-encrypt the objects with SSE-S3.
- Same-account confusion on ownership. Everything in Check 4 applies here too, because CloudFront reads with the same rules.
- A brand-new bucket. For up to 24 hours after you create a bucket, its name is still spreading across Regions, and requests sent to an endpoint outside the bucket's own Region can get a
307 Temporary Redirect. With CloudFront that happens when the origin uses the default S3 endpoint, which sits in us-east-1. Use the Regional origin domain instead, such asbucket.s3.us-west-2.amazonaws.comfor a bucket in Oregon.
Ethan: "The website endpoint plus CloudFront plus 'public' combination works, but you've bought HTTPS and kept the bucket exposed. If HTTPS is the reason you added CloudFront, the private-bucket route below gets you both."
What it costs: a worked example for Jake's booking page
Jake's next question, naturally, was whether all this would cost more than his coffee budget. Here's the arithmetic for a small static site. Prices are US East (N. Virginia) rates as of September 29, 2026. Other Regions differ.
Jake's site is 2 GB stored in S3 Standard (a lot of phone-repair photos), gets 60,000 visits a month, and each visit loads 25 files totaling about 2 MB. The rates: storage $0.023 per GB-month for the first 50 TB, GET requests $0.0004 per 1,000, and data transfer out to the internet $0.09 per GB. The first 100 GB per month of transfer out is free, aggregated across all AWS services and Regions (China and GovCloud excluded).
| Line item | Arithmetic | Monthly cost |
|---|---|---|
| Storage | 2 GB x $0.023 | $0.05 |
| GET requests | 60,000 visits x 25 files = 1,500,000 requests; 1,500 x $0.0004 | $0.60 |
| Data transfer out (best case) | 60,000 x 2 MB = 117.19 GB; minus 100 GB free = 17.19 GB x $0.09 | $1.55 |
| Total, free allowance intact | about $2.20 | |
| Data transfer out (allowance already used elsewhere) | 117.19 GB x $0.09 | $10.55 |
| Total, no free allowance left | about $11.20 |
Three things fall out of the numbers. First, transfer is the line that moves; storage and requests barely register. Second, the free 100 GB is shared across your whole account, so if Jake's backup bucket or his Lambda also sends data out, the website might pay the full rate. Third, once the bucket is public, bots that find it pull files and get billed to you like any other visitor.
Now the part most posts skip: what the 403 itself costs. S3 bills bucket owners for 200 OK and 4XX responses in general, but it doesn't charge you for an AccessDenied 403 when the request comes from outside your own account or organization. So a locked-down bucket shrugs off strangers poking at it for free. One catch for website buckets: request and other charges still apply when S3 returns a custom error document or a custom redirect.
On money for a new account: since July 15, 2025, new AWS accounts get up to $200 in credits that can cover S3, on a free plan that lasts six months. Accounts created before that date stayed on the older 12-month free tier model. A brand-new account won't feel any of this at first, and that's exactly when a public bucket goes unnoticed.
Putting CloudFront in front changes the shape of the bill. Data transferred from S3 to CloudFront isn't charged as S3 data transfer out, and CloudFront then has its own charges. I'm not quoting CloudFront prices here; read the CloudFront pricing page for your Region before you compare.
The sane default: keep the bucket private and let CloudFront do the talking
Everything so far was about making a bucket public so that a website endpoint can serve it. That works, and for a hobby page it's fine. But keeping all four Block Public Access settings on while still hosting a static website is possible with CloudFront origin access control, and for a business page like Jake's I'd call that the default.
✅ Why this is the one to use
Use a private bucket with Block Public Access fully on, a CloudFront distribution with a REST endpoint origin and OAC, and the bucket policy from the previous section. You get HTTPS, you don't expose the bucket to the world, and the 403 causes narrow down to two: the OAC policy and the default root object. If you don't want to run CloudFront yourself, AWS Amplify Hosting is the managed route for HTTPS on content stored in S3.
The steps, in order:
- In S3, leave Block Public Access on for the bucket and the account. You don't need website hosting for this route, so turn it off if you had it on.
- In CloudFront, create a distribution and choose the bucket's REST endpoint (
bucket.s3.Region.amazonaws.com) as the origin, not the website endpoint. - Under origin access, create an origin access control setting and attach it to the origin.
- Set Default root object to
index.html. - Add the OAC bucket policy from the previous section under the bucket's Permissions → Bucket policy, with your own account ID and distribution ID.
- Wait for the distribution to finish deploying, then load the
*.cloudfront.netaddress.
🙋♂️ Jake's Reality Check
"CloudFront sounds like a second bill, and I only have one brain for bills."
The straight answer. It is a second bill, so read the CloudFront pricing page for your Region before you flip. What you buy is a private bucket and HTTPS. And the S3-to-CloudFront transfer isn't billed as S3 data transfer out, so it isn't a double charge on the same bytes.
There's a trade-off I don't want to hide. The website endpoint has features a bare REST origin doesn't: redirects, an index document for folders, and a custom error document configured in S3. If your site relies on redirects configured in S3, you'll need to handle them in CloudFront instead. If you use nothing but a home page and a few files, none of this matters.
Ethan: "Jake, the shop's website is four pages and a price list. You don't need redirects. You need HTTPS and a bucket that isn't shouting your file names at strangers."
Automation: find every risky bucket before a visitor does
If you own more than one bucket, do this once and thank yourself in March. Jake runs it on the last day of the month, since his order-text Lambda only misbehaves at month-end and he'd rather find the other surprises first. The loop below prints the Block Public Access state and the policy status for every bucket you own. It uses only read commands.
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
echo "== $b"
aws s3api get-public-access-block --bucket "$b" \
--query PublicAccessBlockConfiguration --output json 2>&1
aws s3api get-bucket-policy-status --bucket "$b" \
--query PolicyStatus.IsPublic --output text 2>&1
done
A bucket with no public access block configured returns an error rather than four true values, and a bucket without a policy returns an error for policy status, so expect some noise on the tidy ones. The buckets to look at first are those where IsPublic is true or where the four settings are false.
For ongoing detection, the AWS Config managed rules s3-bucket-public-read-prohibited and s3-bucket-public-write-prohibited flag buckets that allow public read or write. In an organization, the Block Public Access policy attached at the root or OU is the stronger control, and attaching it is recorded in AWS CloudTrail so you can see who did it and when.
For the CLI permissions this loop needs, you'll need s3:GetBucketPublicAccessBlock and s3:GetBucketPolicyStatus on each bucket, plus s3:ListAllMyBuckets to list them. If some rows come back as access errors, that's your own IAM permissions talking, not the bucket's.
When nothing works: what to collect, in order
If you've been through all eight checks and the page still says 403, stop changing things. Every extra change makes the picture harder to read. Collect the following, in this order, and you'll usually spot the answer yourself before you send anything to anyone.
- The exact URL and the exact response. From a terminal:
curl -I http://your-bucket.s3-website-us-east-1.amazonaws.com/index.html(use your own endpoint). Note the status line and whether the body is an HTML page (website endpoint) or XML (REST endpoint). - Object facts. Run
aws s3api head-object --bucket your-bucket --key index.html. Look forServerSideEncryption, and confirm the object exists. - Ownership. Compare
aws s3api list-buckets --query Owner.IDwith the owner of the object fromlist-objects. - Block Public Access, all layers.
aws s3api get-public-access-block --bucket your-bucketandaws s3control get-public-access-block --account-id YOUR_ID, and ask whoever runs your organization whether an S3 policy is attached to your account. - The policy.
aws s3api get-bucket-policy --bucket your-bucket. Read every Deny and every condition. - Requester Pays and hosting state. Check Properties for both.
- SCPs. Ask the management account owner whether any Deny for
s3:GetObjectexists at the root, your OU, or your account.
If you open a support case, or post a question on re:Post, paste those results, minus your account ID and any secret Referer token. A case that says 'my S3 site gives 403' gets a question back. A case that says 'the object exists, is owned by the bucket owner, has no KMS encryption, Block Public Access is off at the bucket, the account and the organization, and the policy has this exact statement' gets an answer.
What I'd expect from support, and what I wouldn't: they can look at your account's configuration in ways you can't and tell you which setting is denying the request. They can't undo a Deny your own organization wrote, they can't make an object readable that another account owns without that account's cooperation, and they can't make a website endpoint speak HTTPS. If your last blocker is a company policy, the fix is a conversation with the person who owns that policy, not a ticket.
Ethan: "A 403 is one of the most solvable errors in AWS, Jake, because it's always a rule somebody wrote. It's never a mystery. It's just a rule you haven't read yet."
FAQ: the questions people actually type
Why do I get 403 Forbidden on my S3 static website?
The object exists but the public isn't allowed to read it. On the website endpoint, a missing file returns 404 and an unreadable file returns 403. The usual causes, in order, are Block Public Access still on at the bucket, account or organization level, no bucket policy granting s3:GetObject to everyone, objects owned by another account, an explicit Deny, KMS-encrypted objects, and Requester Pays.
How do I fix 403 Forbidden Code: AccessDenied on an S3 static website?
Turn off Block Public Access on the bucket, check the account and organization layers, add a bucket policy allowing s3:GetObject for Principal: * on arn:aws:s3:::your-bucket/*, and load the website endpoint over http. If it still fails, check object ownership, explicit Deny statements, KMS encryption and Requester Pays in that order.
Why is my S3 bucket public but still returns 403?
Public at the bucket level isn't enough. Block Public Access at the account or organization level can still override it, because S3 applies the most restrictive combination. Objects can also be owned by another account, encrypted with KMS, or covered by a Deny. Check aws s3control get-public-access-block for the account layer.
S3 static website 403 Forbidden still not working after adding a bucket policy. What now?
Make sure the policy actually saved and that Resource ends in /* with the exact bucket name. Then check whether Block Public Access is still on at the account or organization level, whether the objects are owned by another account, whether they're KMS-encrypted, and whether a Deny statement or Requester Pays is in play.
Why does my S3 website not work with https?
S3 website endpoints don't support HTTPS. Use http://bucket-name.s3-website-Region.amazonaws.com. For HTTPS, put a CloudFront distribution in front of the bucket, or use AWS Amplify Hosting to serve content stored in S3.
What is the difference between the S3 website endpoint and the REST endpoint?
The website endpoint serves only publicly readable content, returns HTML errors, supports redirects and index documents, allows only GET and HEAD, and doesn't support HTTPS. The REST endpoint supports public and private content, returns XML errors, and allows all bucket and object operations. Use the website endpoint for a public site and the REST endpoint for programs.
Why do I get 403 on the root URL but /index.html works?
The index document isn't configured or doesn't match. Set the Index document under Properties, Static website hosting, to index.html with no leading slash and matching case. Behind CloudFront, set the Default root object to index.html as well.
Why does S3 return 403 instead of 404 for missing files?
On the website endpoint a missing object returns 404. A 403 for a missing object appears when the caller lacks s3:ListBucket, so S3 doesn't reveal whether the object exists. That can happen through CloudFront or the REST endpoint. Use aws s3api head-object to find out whether the file exists.
Do I need to make my S3 bucket public to host a static website?
For a website endpoint, yes, because it serves only publicly readable content. If you'd rather keep Block Public Access on, use CloudFront with origin access control and a REST endpoint origin, and leave the bucket private.
How do I fix Policy has invalid resource in the S3 bucket policy editor?
The bucket name inside the Resource ARN doesn't match the bucket you're editing. Replace the placeholder with the exact bucket name and keep the trailing /*, for example arn:aws:s3:::your-bucket/*.
Why can't I save my S3 bucket policy after I turned off Block Public Access?
The account level may still be blocking public policies, or an organization-level Block Public Access policy is attached above your account. S3 applies the most restrictive combination. Check S3, Block Public Access settings for this account, and run aws s3control get-public-access-block --account-id YOUR_ID. An organization policy has to be changed in AWS Organizations.
Why does CloudFront return 403 when my origin is an S3 website endpoint?
A website endpoint serves only public objects, so the bucket must allow public reads with Block Public Access off. Also check the Default root object, KMS-encrypted objects, explicit Deny statements, Requester Pays, and, if you use a Referer header, that the bucket policy token matches the CloudFront origin custom header.
Can I use CloudFront OAC with an S3 static website endpoint?
Not usefully. OAC exists to keep a bucket private, and a website endpoint serves only public content, so the two work against each other. Use a REST endpoint with OAC and a private bucket, or a website endpoint with a public bucket.
Why do KMS-encrypted objects return 403 on an S3 website?
AWS KMS doesn't support anonymous requests, and public access on a bucket doesn't apply to objects encrypted with KMS. Change the objects to SSE-S3, for example by copying each object over itself after fixing the bucket's default encryption. Behind CloudFront, OAC can serve KMS-encrypted objects if it has permission on the key.
How do I make S3 objects uploaded by another account publicly readable?
A bucket policy only covers objects the bucket owner owns. Turn on Bucket owner enforced with aws s3api put-bucket-ownership-controls, which disables ACLs and makes you the owner of every object, or have the uploader grant bucket-owner-full-control and then copy each object over itself as the bucket owner. Copying resets storage class and website-redirect-location unless you restate them.
How can I see exactly why S3 is denying my request?
For requests made with credentials inside your own account or organization, S3 now includes extra context in the 403 error: the type of policy that denied access and the reason, and this context also appears in CloudTrail logs. Anonymous website requests get a bare HTML error page, so use head-object, get-public-access-block, get-bucket-policy and server access logging to work out the cause.
If you've read this far because a page you care about is showing a white 403 right now, take a breath: the fix is almost certainly one of the first three checks, and the rest are here so you never have to search a second time. If I've missed the case you're stuck on, send me the exact error text and the check you got to, and I'll add it.
📌 If you keep one line from this page
On an S3 website endpoint, a 403 means the file exists and something, at the bucket, the account, the organization, the owner or a Deny, won't let the public read it.
A 404 means it's missing; a 403 means it's there but blocked.
Revision note. I wrote this on September 29, 2026. If you are reading it much later, re-check the $0.0004 per 1,000 GET requests and the $0.09 per GB transfer-out rate first, then whether your organization has attached a Block Public Access policy since you last looked. If a site that ran for years went white on a Saturday morning, that is a maddening way to lose a booking. Work down the list; one rule in there is the one that changed.