Frontend Labs
⋯

61. CORS: the browser's cross-origin rules

61.1 What problem it solves

The same-origin policy stops evil.com's JavaScript from reading bank.com's responses. But legitimate cross-origin APIs exist. CORS is the server's way of granting exceptions — enforced entirely by the browser.

CORS is not server security. It restricts what browser JavaScript may read. curl, mobile apps, and servers ignore it completely. Never rely on CORS to protect data.

An origin is scheme://host:port. Different port or scheme = different origin.

61.2 Simple vs preflighted requests

SIMPLE REQUEST (no preflight):
  method is GET, HEAD, or POST
  AND only "CORS-safelisted" headers
  AND Content-Type is one of:
      application/x-www-form-urlencoded, multipart/form-data, text/plain

  -> browser sends the request directly, with Origin: header
  -> then CHECKS the response headers before letting JS read it
     (the request WAS sent -- side effects already happened)

PREFLIGHTED REQUEST:
  anything else (PUT/DELETE/PATCH, Content-Type: application/json,
  custom headers like Authorization or X-Api-Key)

  -> browser first sends OPTIONS:
       OPTIONS /resource
       Origin: https://app.example.com
       Access-Control-Request-Method: PUT
       Access-Control-Request-Headers: content-type, authorization
  -> server must answer with matching Access-Control-Allow-* headers
  -> only then does the browser send the real request

Content-Type: application/json is what triggers a preflight for most APIs. That's why "my GET works but my POST doesn't" is the archetypal CORS complaint.

61.3 The response headers

Header Purpose
Access-Control-Allow-Origin The allowed origin, or *. Cannot be * when credentials are used.
Access-Control-Allow-Methods Methods allowed (preflight response)
Access-Control-Allow-Headers Request headers allowed (preflight response)
Access-Control-Allow-Credentials: true Permit cookies / HTTP auth. Requires an explicit origin.
Access-Control-Expose-Headers Which response headers JS may read (by default only a safelist)
Access-Control-Max-Age How long the browser may cache the preflight (seconds)

61.4 The four failures you will actually hit

  1. Allow-Origin: * with credentials: 'include' → blocked. You must echo the specific origin and send Allow-Credentials: true and add Vary: Origin (or a shared cache will serve the wrong origin's header).
  2. Preflight not handled. Your framework routes PUT /x but returns 404/405 for OPTIONS /x. Handle OPTIONS explicitly.
  3. Custom header not listed. You send X-Request-Id; the preflight response doesn't list it in Allow-Headers. Blocked.
  4. Reading a response header fails. JS can only see a safelisted set unless you add Access-Control-Expose-Headers.

Also: an error response still needs CORS headers. A 500 without Access-Control-Allow-Origin shows up in the browser as a CORS error, hiding the real failure. Add the headers in your error handler too.

61.5 Real-world example

An API behind a CDN echoed the request Origin into Access-Control-Allow-Origin — correct — but forgot Vary: Origin. The CDN cached the response for https://app-a.example and served it, headers and all, to https://app-b.example, which the browser then blocked. Intermittent, only under cache hits, and invisible in origin logs. Fix: one header.

Try it yourself

# simulate a preflight
curl -sI -X OPTIONS https://api.example.com/v1/items \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: content-type' | grep -i 'access-control\|vary'

# a simple request
curl -sI https://api.example.com/v1/items -H 'Origin: https://app.example.com' \
  | grep -i 'access-control'

Generated from real-life-example.md · 8 Oct 2026 16:36