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
Allow-Origin: *withcredentials: 'include'→ blocked. You must echo the specific origin and sendAllow-Credentials: trueand addVary: Origin(or a shared cache will serve the wrong origin's header).- Preflight not handled. Your framework routes
PUT /xbut returns 404/405 forOPTIONS /x. HandleOPTIONSexplicitly. - Custom header not listed. You send
X-Request-Id; the preflight response doesn't list it inAllow-Headers. Blocked. - 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'