DEV Community

CloudSkill Consulting LLC
CloudSkill Consulting LLC

Posted on

Refresh a React Route on S3 + CloudFront and Get AccessDenied? Fix It Without Hiding Your API 404s

As of October 27, 2026, this post sums up the official pages linked here.

The problem

Someone on Reddit had a React app built with create-react-app and react-router. They put the build in an S3 bucket and served it through CloudFront. Moving around inside the app was fine. But if they pasted a deeper link like /some-path into a new tab, or pressed refresh on that page, they got an XML page that said AccessDenied. They asked how to send every path to index.html while still letting the app see the real path.

Plenty of teams hit this with single-page apps (SPAs) on AWS. The quick answer most people get is "add a custom error page." That answer works, but it has a side effect that often shows up weeks later. This post explains the root cause, why the quick answer can hurt, and a narrower fix with tested code.

Root cause: two kinds of navigation

A React app with BrowserRouter handles routes in the browser:

  1. You open https://example.com/. CloudFront returns index.html, and the JavaScript loads.
  2. You click a link to /dashboard. The router changes the address bar with the History API and draws the new screen. No request goes to CloudFront for /dashboard.
  3. You press refresh. Now the browser really does send GET /dashboard to CloudFront.

CloudFront passes that request to the S3 origin, and S3 looks for an object with the key dashboard. There isn't one. Your build only has index.html, a few files under assets/ or static/, and some icons.

Why 403 and not 404? With origin access control (OAC), CloudFront signs requests to a private bucket. AWS notes that if the bucket policy for OAC doesn't include s3:ListBucket, S3 answers a missing key with 403 instead of 404. That is the AccessDenied page from the thread. It isn't a permissions bug; it's a missing file.

So the fix must do one thing: when the browser asks for an app route, CloudFront should serve index.html, and the app should see the original URL. (The address bar keeps the original URL, so react-router still sees /dashboard.)

The common fix, and its catch

The usual advice, which also appears in AWS re:Post, is to add two custom error responses on the distribution:

  • 403 → /index.html, response code 200
  • 404 → /index.html, response code 200

It works for routes. Here is the catch: custom error responses are set on the distribution, not on a cache behavior. In the CloudFront API, CustomErrorResponses sits in DistributionConfig, and there's no such field in CacheBehavior. So the rule applies to every path the distribution serves.

That leads to three problems people hit later:

  • API errors turn into HTML. If /api/* goes to an API origin through the same distribution, an API 404 or 403 comes back as 200 with your index.html. The frontend's fetch succeeds, response.json() fails, and you see Unexpected token '<' in the console instead of a clear 404.
  • Missing assets look like success. After a deploy, an old tab may ask for /assets/main.0a1b2c.js, which no longer exists. Instead of a 404, it gets index.html with status 200. The browser then refuses to run HTML as a script, and the error points you in the wrong direction.
  • Real access problems disappear. If a bucket policy breaks, every request still returns 200. Your error-rate graph stays flat while users see a blank page.

None of this means the error-page fix is wrong. For a site with only static files and no API behind the same distribution, it's simple and fine. Once the distribution serves more than the SPA, a narrower fix is safer.

A narrower fix: rewrite only app routes

Instead of reacting to errors, decide before the request reaches S3. A CloudFront Function on the viewer request event can change request.uri. You attach it only to the cache behavior that serves the static site, so the API behavior never runs it.

The rule:

  • Paths under /api/ (and /.well-known/) pass through untouched.
  • If the final path segment has a dot (main.0a1b2c.js, logo.svg, favicon.ico), treat it as a file and pass it through. A missing file still gets a real error.
  • Everything else is an app route. Rewrite it to /index.html.
// CloudFront Function, runtime cloudfront-js-2.0, event: viewer-request.
// Sends SPA routes to /index.html. Leaves real files and API calls alone.
const PASS_THROUGH_PREFIXES = ['/api/', '/.well-known/'];

function handler(event) {
  const request = event.request;
  const uri = request.uri;

  // 1. API and other non-SPA paths go to their origin untouched.
  //    (Plain index loop: for...of is not on the runtime's supported list.)
  for (let i = 0; i < PASS_THROUGH_PREFIXES.length; i++) {
    if (uri.startsWith(PASS_THROUGH_PREFIXES[i])) {
      return request;
    }
  }

  // 2. Anything whose last segment has a dot is a file (main.4f1c.js, logo.svg).
  //    Keep it, so a missing file still returns a real error.
  const lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
  if (lastSegment.includes('.')) {
    return request;
  }

  // 3. Everything else (/, /dashboard, /orders/42/) is a client-side route.
  request.uri = '/index.html';
  return request;
}
Enter fullscreen mode Exit fullscreen mode

A few notes on the code:

  • It uses only features the CloudFront Functions JavaScript runtime 2.0 lists as supported: const, let, String.prototype.startsWith() and String.prototype.includes(). The statement list in that runtime doesn't include for...of, so the loop uses an index.
  • It changes only request.uri. The browser's address bar still shows /dashboard, so the router renders the right screen.
  • The /api/ check is a safety net. The real protection is that the API has its own cache behavior with no function attached.

Wire it up with Terraform

This config creates the function, attaches it to the default (site) behavior and gives /api/* its own behavior. Note what's missing: there are no custom_error_response blocks.

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
}

variable "site_bucket_regional_domain" {
  type        = string
  description = "Regional domain of the private S3 bucket, e.g. my-site.s3.us-east-1.amazonaws.com"
}

variable "api_domain" {
  type        = string
  description = "Domain of the API origin, e.g. api.example.com"
}

resource "aws_cloudfront_function" "spa_rewrite" {
  name    = "spa-rewrite"
  runtime = "cloudfront-js-2.0"
  comment = "Send client-side routes to /index.html"
  publish = true
  code    = file("${path.module}/spa-rewrite.js")
}

resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "site-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  default_root_object = "index.html"

  origin {
    origin_id                = "site"
    domain_name              = var.site_bucket_regional_domain
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
  }

  origin {
    origin_id   = "api"
    domain_name = var.api_domain
    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  # Static site: the rewrite runs here only.
  default_cache_behavior {
    target_origin_id       = "site"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.spa_rewrite.arn
    }
  }

  # API: no rewrite, so a real 404 from the API stays a 404.
  ordered_cache_behavior {
    path_pattern             = "/api/*"
    target_origin_id         = "api"
    viewer_protocol_policy   = "https-only"
    allowed_methods          = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods           = ["GET", "HEAD"]
    cache_policy_id          = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # Managed-CachingDisabled
    origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # Managed-AllViewerExceptHostHeader
  }

  # Note: no custom_error_response blocks. Those apply to the whole distribution.

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}
Enter fullscreen mode Exit fullscreen mode

The three policy IDs are the AWS managed policies named in the comments. Each ID was matched against the AWS pages for managed cache policies and managed origin request policies. The S3 bucket policy that lets this OAC read the bucket is left out to keep the example short; the OAC page in the docs list below shows it.

Trade-offs

No fix is free. Here is what this one costs and where it can bite:

  • Dots in route names. A route like /users/jane.doe looks like a file to this rule, so it won't be rewritten. Either keep dots out of route segments, or switch rule 2 to a short list of real file extensions.
  • Unknown routes return 200. /no-such-page now gets index.html with status 200. Your app must show its own "not found" screen. Search engines may treat that as a soft 404, so if SEO matters, render a not-found page and consider prerendering key routes.
  • A function runs on every site request. On pay-as-you-go pricing, CloudFront Functions are billed per invocation. Flat-rate plans count usage differently. Check the CloudFront pricing page against your traffic before you assume it's free.
  • Two places to change. New top-level paths that should skip the rewrite (say /auth/) need a new cache behavior, the prefix list in the function, or both.
  • Why not the S3 website endpoint and its error document? The OAC docs say a bucket set up as a website endpoint must be a custom origin, so you can't use OAC with it. You'd give up a private bucket to get the routing.

If your distribution only serves the SPA, the two custom error responses are still a fair choice. Just know they act on every path.

How to verify the fix

1. Test the logic locally. CloudFront Functions don't use module.exports, so load the file the way a plain script runs and call handler directly:

// test-spa-rewrite.mjs  (run: node test-spa-rewrite.mjs)
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';

const ctx = {};
vm.runInNewContext(readFileSync(new URL('./spa-rewrite.js', import.meta.url), 'utf8'), ctx);

const cases = [
  ['/', '/index.html'],
  ['/dashboard', '/index.html'],
  ['/orders/42/', '/index.html'],
  ['/settings/profile', '/index.html'],
  ['/index.html', '/index.html'],
  ['/assets/main.4f1c9a.js', '/assets/main.4f1c9a.js'],
  ['/assets/old.0000.js', '/assets/old.0000.js'],
  ['/favicon.ico', '/favicon.ico'],
  ['/api/orders/42', '/api/orders/42'],
  ['/.well-known/security.txt', '/.well-known/security.txt'],
  ['/v1.2/release-notes', '/index.html'],
];

for (const [input, expected] of cases) {
  const event = { version: '1.0', context: { eventType: 'viewer-request' },
                  request: { method: 'GET', uri: input, querystring: {}, headers: {}, cookies: {} } };
  const out = ctx.handler(event);
  assert.equal(out.uri, expected, `${input} -> ${out.uri}, expected ${expected}`);
  console.log(`ok  ${input.padEnd(28)} -> ${out.uri}`);
}
console.log(`${cases.length} cases passed`);
Enter fullscreen mode Exit fullscreen mode

On Node 20 all 11 cases pass. The last case matters: /v1.2/release-notes has a dot in a folder name, but the rule only looks at the last segment, so it's still treated as a route. Node is more relaxed than the CloudFront runtime, so a local pass checks the logic, not runtime support. That's what the next step is for.

2. Check the Terraform. terraform fmt -check, terraform validate and terraform plan should show three resources and one function_association with event_type = "viewer-request". This config passed all three with Terraform v1.9.8 and the AWS provider v6.70.0.

3. Test in CloudFront before it goes live. Save one request as event.json:

{
  "version": "1.0",
  "context": { "eventType": "viewer-request" },
  "viewer": { "ip": "198.51.100.10" },
  "request": { "method": "GET", "uri": "/dashboard", "querystring": {}, "headers": {}, "cookies": {} }
}
Enter fullscreen mode Exit fullscreen mode

Then run it against the function's development stage:

ETAG=$(aws cloudfront describe-function --name spa-rewrite --query ETag --output text)
aws cloudfront test-function --name spa-rewrite --if-match "$ETAG" \
  --stage DEVELOPMENT --event-object fileb://event.json \
  --query 'TestResult.FunctionOutput' --output text
Enter fullscreen mode Exit fullscreen mode

The output should show "uri":"/index.html". Change the URI to /assets/main.0a1b2c.js and /api/x and check that they come back unchanged.

4. Check the live distribution. After deployment finishes, with your real domain:

curl --silent --output /dev/null --write-out '%{http_code} %{content_type}\n' https://example.com/dashboard
# expect: 200 text/html

curl --silent --output /dev/null --write-out '%{http_code}\n' https://example.com/assets/does-not-exist.js
# expect: 403 or 404, not 200

curl --silent --output /dev/null --write-out '%{http_code} %{content_type}\n' https://example.com/api/does-not-exist
# expect: whatever your API returns, e.g. 404 application/json, not 200 text/html
Enter fullscreen mode Exit fullscreen mode

If the third line comes back 200 text/html, a custom error response is still set on the distribution. Remove it.

Before you ship

  • [ ] No CustomErrorResponses for 403/404 on a distribution that also serves an API
  • [ ] The function is on the site behavior only, on viewer-request
  • [ ] /api/* has its own behavior with no function
  • [ ] Route segments don't contain dots, or rule 2 uses an extension list
  • [ ] The app shows its own not-found screen
  • [ ] Missing assets return 403 or 404, never 200

If you got here by searching for CloudSkill Consulting LLC software engineering, that's us. We wrote this fix for teams that serve a frontend and an API from one CloudFront distribution.

Docs checked (October 26, 2026)


Spotted an AWS default or limit that changed since we wrote this? Drop a line to info@cloudskillconsulting.com so the post stays accurate.

Top comments (0)