Request a page that does not exist on a static site served from S3 behind CloudFront, and you will probably get 403 Forbidden rather than 404 Not Found.
$ curl -o /dev/null -w "%{http_code}\n" https://example.com/no-such-page/
403
The site is fine. Every real page returns 200. But a typo in a URL, a renamed post, or a crawler following a stale link all produce a permissions error rather than a missing-page error.
Why it happens
It is a consequence of using Origin Access Control, which is the current recommended way to lock an S3 bucket to a single CloudFront distribution.
With OAC, the bucket policy grants s3:GetObject to the distribution and nothing else. It does not grant s3:ListBucket. That is deliberate: without ListBucket, nobody can enumerate the contents of the bucket.
S3’s behaviour follows from that. When a caller asks for a key that does not exist:
- With
ListBucketpermission, S3 can tell you the key is genuinely absent, so it returns404 NoSuchKey. - Without
ListBucketpermission, S3 will not confirm or deny that the key exists, because doing so would leak information about bucket contents. It returns403 AccessDeniedinstead.
So the 403 is not a misconfiguration. It is S3 declining to tell an unauthorised caller whether something is there. CloudFront then passes that status through unchanged, because by default it has no opinion about error responses.
This is also why the behaviour differs from the older S3 website-endpoint setup. A website endpoint is public and does return 404, but it cannot be locked down with OAC and does not support HTTPS to the origin.
Why it matters
Mostly it is a correctness problem rather than an outage:
- Crawlers treat them differently. A 403 says “this exists and you may not have it.” A 404 says “this is not here.” Search engines will keep retrying a 403 and may leave the URL in the index.
- Your 404 page never renders. Hugo, Next.js and most static generators build a
404.html. Without error mapping it is simply never served. - It reads badly. A visitor who mistypes a URL gets a permissions error from your site, which suggests something is locked rather than missing.
The fix
Add custom error responses to the distribution. Two entries, mapping both statuses onto your error page and rewriting the status code:
aws cloudfront get-distribution-config --id <YOUR_DISTRIBUTION_ID> > dist.json
Pull the ETag from the top of that file, edit the DistributionConfig object, and set:
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/404.html",
"ResponseCode": "404",
"ErrorCachingMinTTL": 300
},
{
"ErrorCode": 404,
"ResponsePagePath": "/404.html",
"ResponseCode": "404",
"ErrorCachingMinTTL": 300
}
]
}
Then push just the DistributionConfig back, with the ETag as --if-match:
aws cloudfront update-distribution \
--id <YOUR_DISTRIBUTION_ID> \
--distribution-config file://config-only.json \
--if-match <ETAG_FROM_ABOVE>
Deployment takes a few minutes. Poll for it rather than guessing:
until [ "$(aws cloudfront get-distribution --id <YOUR_DISTRIBUTION_ID> \
--query 'Distribution.Status' --output text)" = "Deployed" ]; do sleep 15; done
Then verify:
$ curl -o /dev/null -w "%{http_code}\n" https://example.com/no-such-page/
404
Three things worth knowing
Map 404 as well as 403. The 403 is what you are seeing today, but it is a side effect of the OAC bucket policy. Change the origin setup later, or grant ListBucket for some other reason, and S3 starts returning real 404s. Mapping both means the behaviour is correct either way.
update-distribution takes the config object, not the wrapper. get-distribution-config returns {"ETag": ..., "DistributionConfig": {...}}. You must send the inner object alone. Sending the whole wrapper is the most common way this call fails.
Watch ErrorCachingMinTTL. It is how long CloudFront caches the error before asking the origin again. The default of 10 seconds means every 404 hits your origin repeatedly. 300 is a reasonable floor for a static site. Raise it and a newly published page may 404 for the length of the TTL if anyone requested it while it was still missing.
A note on SPAs
If you are serving a single-page app rather than a static site, the same two entries are used to route unknown paths into the client-side router, but with "ResponseCode": "200" and "ResponsePagePath": "/index.html".
Be deliberate about which you are building. Returning 200 for every unknown URL is correct for an SPA and actively wrong for a content site, where it produces soft 404s that search engines dislike more than the original problem.