skip to content

A React SPA served by CloudFront from a private S3 bucket origin loads fine at the root, but opening https://app.example.com/orders/42 directly returns an AccessDenied error page. Why, and which CloudFront distribution setting fixes it?

level: middleimportance: should knowfreq 44%

answer

  1. no object exists at that key
  2. no list permission means 403, not 404
  3. serve the app shell instead
  4. rewrite the status code to 200
  5. the setting is distribution-wide

basics

~20 s

There is no object at key orders/42, and because the bucket policy grants read but not list, S3 answers 403 AccessDenied rather than 404. Add a CloudFront custom error response mapping 403 to /index.html with response code 200 so the SPA router handles the path.

solid answer

~50 s

The SPA's routes exist only in the browser; S3 has no `orders/42` object. Since the origin-access policy grants `s3:GetObject` but not `s3:ListBucket`, S3 refuses to confirm whether the key exists and returns **403 AccessDenied**, which CloudFront forwards to the viewer. The fix is a **custom error response** on the distribution: for HTTP error code 403, set the response page path to `/index.html` and the **response code to 200**, with a short error-caching minimum TTL. CloudFront then serves the app shell for any unknown path and the client-side router renders the right view. Two things to watch: the response code must be rewritten to 200, or the browser gets the shell with an error status and search engines index it as broken; and custom error responses are configured **per distribution, not per behavior**, so if the same distribution also fronts an API, that API's genuine 403 and 404 responses get replaced by HTML too.

code

json · 13 lines
json
{
  "CustomErrorResponses": {
    "Quantity": 1,
    "Items": [
      {
        "ErrorCode": 403,
        "ResponsePagePath": "/index.html",
        "ResponseCode": "200",
        "ErrorCachingMinTTL": 10
      }
    ]
  }
}

go deeper

for a junior

Know that an SPA's routes are client-side, so no file exists at a deep-link path, and that CloudFront can be told to serve index.html for that error instead.

for a middle

Explain precisely why S3 answers 403 rather than 404 under an origin-access policy, and configure the custom error response with the response page path, the 200 rewrite and a sane error-caching TTL.

for a senior

Anticipate the blast radius: this is distribution-level configuration that will rewrite an API's error statuses, break JSON clients and silence 4xx alarms, so scope it deliberately or split the distribution.

for a principal

Decide the estate-wide pattern for hosting SPAs — error rewriting versus edge path rewriting versus server-side rendering — and what that choice implies for observability, SEO and shared distributions.

## Why the error is 403 and not 404 A single-page app owns its routes in JavaScript. The bucket contains `index.html` plus hashed bundles and nothing at the key `orders/42`. What happens next depends on permissions, and this is the part candidates usually miss. The bucket policy written for origin access grants exactly `s3:GetObject`. It does not grant `s3:ListBucket`. S3's deliberate behaviour is that a caller without list permission is not told whether an object exists — revealing 404 versus 403 would leak the key space — so **every miss comes back as 403 AccessDenied**. CloudFront passes the origin's status through, and the user sees an XML or generic error page instead of the app. (If you had granted `s3:ListBucket`, the same request would return 404 instead — the symptom changes, the underlying "no such object" cause does not.) ## The fix: custom error responses A CloudFront distribution can intercept selected origin error statuses and substitute a page from an origin, optionally rewriting the status code: ```json { "CustomErrorResponses": { "Quantity": 2, "Items": [ { "ErrorCode": 403, "ResponsePagePath": "/index.html", "ResponseCode": "200", "ErrorCachingMinTTL": 10 }, { "ErrorCode": 404, "ResponsePagePath": "/index.html", "ResponseCode": "200", "ErrorCachingMinTTL": 10 } ] } } ``` Three fields matter: - **`ResponsePagePath`** must be a path the distribution can serve, so `/index.html` resolves through the default behavior back to the bucket. - **`ResponseCode` = 200** is what makes it a rewrite rather than a custom error page. Leave it unset and the shell is delivered with a 403 status: the router still works, but crawlers treat every deep link as an error, some client libraries refuse the response, and monitoring fills with false alarms. - **`ErrorCachingMinTTL`** controls how long CloudFront remembers the error. The default is 10 seconds; setting it to minutes means a genuinely missing asset stays "missing" at the edge long after you fix it. ## The trap: it is distribution-wide Custom error responses are a property of the distribution, not of a cache behavior. If the same distribution also routes `/api/*` to an ALB — the common SPA-plus-API layout — then: - an API returning 404 for an unknown resource now returns **200 with HTML**, and the front end's `fetch` chokes parsing JSON; - an API returning 403 for an expired session now returns 200, so the client never triggers its re-authentication path; - alarms built on 4xx rates at the edge go quiet, because those statuses no longer exist in the logs. Mitigations, roughly in order of preference: map only 403 and not 404 if your API distinguishes them; move the API to its own distribution or hostname; or rewrite the request path at the edge with a viewer-request function so the origin never errors in the first place (that edge-function approach is a different mechanism with its own tradeoffs). ## Alternatives worth naming - **S3 static-website endpoint** with `index.html` as both index and error document. This gives S3-side handling of directory indexes and errors, but the website endpoint is public HTTP: you give up origin access control and the bucket must be readable by the world. For a private SPA, that is not a trade you make. - **Prerendering or SSR** behind a compute origin, which changes the problem entirely — there is a real response for every route. ## What good sounds like in an interview Name the cause (no such key, 403 because list is not granted), name the exact fix (custom error response 403 → `/index.html` with response code 200), and then volunteer the blast radius — that this setting is distribution-wide and will rewrite an API's errors too. The last part is what separates someone who has read a blog post from someone who has shipped it.

  • Why is rewriting the response code to 200 important rather than cosmetic?
    Without it CloudFront returns `index.html` with a 403 or 404 status. The router still renders, but crawlers index every deep link as an error page, some HTTP clients and service workers refuse or discard the body, and edge 4xx alarms fire continuously on normal traffic. The rewrite makes a valid page report as valid.
  • The same distribution also serves `/api/*` from an ALB. What does this custom error response do to the API?
    Custom error responses apply to the whole distribution, so the API's genuine 403 and 404 responses are replaced by `index.html` with status 200. Clients parsing JSON break, expired-session handling never triggers, and 4xx metrics vanish. Map only the code the SPA needs, or give the API its own distribution or hostname.
  • Would granting `s3:ListBucket` in the bucket policy be a reasonable fix?
    It changes the symptom to a 404 instead of a 403 but does not serve the SPA, and it widens the grant so the origin can enumerate keys. You would still need the custom error response, now mapped on 404. Keep the grant minimal and map the status you actually get.

saying these in an interview costs you the question

  • Blames DNS or the router config instead of the missing object
  • Expects S3 to return 404 when list permission is absent
  • Leaves the response code as 403 while serving index.html
  • Assumes custom error responses are per cache behavior
  • Switches to the public website endpoint and calls it solved

context