CORS with S3 and Localhost: Browser Enforcement, Null Origin, and Preflight

2 min readWeb Development

CORS is enforced by browsers, not servers — curl and backend code bypass it entirely. S3 CORS configuration requires exact origin matching: localhost:5173 is a different origin from localhost:3000. Opening HTML from file:// sets origin=null, which never matches any AllowedOrigins entry. The test for whether CORS is the problem: if JS can see the HTTP status code (403, 404), CORS is not blocking the request.

securityawss3cors

CORS is a browser policy, not a server restriction

CORS errors mean a browser refused to expose a cross-origin response to JavaScript — the request still went to the server. Backend code, curl, and Postman have no CORS concept:

| Client | CORS applies? | What happens | |---|---|---| | Browser (frontend JS) | Yes | Browser blocks response if Access-Control-Allow-Origin missing/wrong | | Backend server (Node/Go/Python) | No | HTTP request proceeds, response always accessible | | curl / Postman | No | No browser to enforce CORS policy |

If your backend proxies an S3 request, CORS doesn't apply. CORS only matters when the browser's JavaScript makes the request directly to S3.

The diagnostic: if JavaScript can read the HTTP status code, CORS is not the problem.

try {
    const response = await fetch('https://my-bucket.s3.amazonaws.com/file.json');
    // If you get here and can read response.status, CORS is not blocking you.
    // A 403 here means credentials/bucket policy issue, not CORS.
} catch (e) {
    // TypeError: Failed to fetch
    // This is what CORS blocking looks like — network error, no status code visible.
}

S3 CORS configuration

S3 CORS is configured per-bucket. Each rule specifies which origins, methods, and headers are allowed:

[
  {
    "AllowedOrigins": ["https://app.example.com", "http://localhost:5173"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "x-amz-request-id"],
    "MaxAgeSeconds": 3000
  }
]

S3 matches origins exactly as strings. http://localhost:5173 and http://localhost:3000 are different origins — both must be listed if both are used. Wildcards are not supported in AllowedOrigins for S3.

MaxAgeSeconds controls preflight caching. For GET requests with standard headers, there's no preflight. For PUT, DELETE, or custom headers, the browser sends an OPTIONS preflight first:

OPTIONS /file.json HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, PUT, POST, DELETE
Access-Control-Max-Age: 3000

Opening an HTML file with file:// sets origin=null — S3 AllowedOrigins cannot match it

GotchaCORS / Browser Security

When you open a file directly in the browser (file:///Users/you/test.html), the page has no real origin. Browsers represent this as the string 'null'. S3's CORS matching compares the request's Origin header against AllowedOrigins entries. 'null' never matches 'http://localhost:5173'. You can't add 'null' to AllowedOrigins to work around this — S3 doesn't support it, and doing so would be a security risk (any opaque origin, including sandboxed iframes, would match). The fix: serve the file over HTTP with a local server.

Prerequisites

  • same-origin policy
  • HTTP Origin header
  • CORS preflight

Key Points

  • file:// origin is the string 'null' in the Origin header — not the same as no header.
  • S3 cannot be configured to allow 'null' origin — use a local HTTP server instead.
  • npx http-server -p 5173 or python3 -m http.server 5173 serves files with a real HTTP origin.
  • Port is part of the origin: http://localhost:5173 ≠ http://localhost:3000 — both must be in AllowedOrigins.

Diagnosing CORS failures

CORS failures are invisible to JavaScript — the browser withholds the response entirely. The symptom is a TypeError: Failed to fetch with no status code. The actual CORS error only appears in the browser DevTools console:

Access to fetch at 'https://my-bucket.s3.amazonaws.com/data.json'
from origin 'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Decision tree:

fetch() throws TypeError: Failed to fetch?
  → Check browser console for CORS error message
    → CORS message present? → S3 CORS config missing/wrong origin
    → No CORS message? → Network error (bucket doesn't exist, DNS issue)

fetch() returns a response but status is 403?
  → CORS is fine (browser received the response)
  → Problem is bucket policy or IAM permissions, not CORS

Localhost CORS allowance applies to any machine:

"AllowedOrigins": ["http://localhost:5173"]

This allows any browser, on any machine, running something on http://localhost:5173 to make cross-origin requests to your S3 bucket. CORS doesn't identify the machine — it compares the literal origin string. This is acceptable for development; never use it in production.

You open test.html directly in Chrome (file:///Users/you/test.html). It fetches an S3 object. S3 CORS allows 'http://localhost:5173'. The fetch fails. Why?

easy

file:// pages send Origin: null in cross-origin requests. S3 compares this against AllowedOrigins.

  • AS3 doesn't serve requests from localhost
    Incorrect.S3 serves requests from any origin — CORS controls whether the browser will expose the response to JavaScript, not whether S3 processes the request.
  • BThe file:// page has origin 'null', which doesn't match 'http://localhost:5173' in AllowedOrigins — serve the file from a local HTTP server instead
    Correct!Browsers assign the string 'null' as the origin for file:// pages. S3 compares the Origin header against AllowedOrigins entries. 'null' ≠ 'http://localhost:5173', so S3 returns a response without Access-Control-Allow-Origin, and the browser blocks JavaScript from reading it. Fix: npx http-server -p 5173 — the page now has origin http://localhost:5173 which matches the AllowedOrigins entry.
  • CCORS is blocked because the file is local — only deployed apps can access S3
    Incorrect.CORS doesn't distinguish local vs deployed. Any HTTP origin listed in AllowedOrigins can access S3. The issue is specifically file:// vs http://.
  • DAllowedMethods must include 'GET' explicitly for this to work
    Incorrect.This would be relevant if AllowedMethods didn't include GET. The question specifies the S3 CORS configuration allows the origin — the null origin mismatch is the actual issue.

Hint:What does a browser send as the Origin header when loading from file://? What does S3 compare that against?