Chapter 42 — CORS: why the browser blocked your request
In one sentence
Browsers stop JavaScript on one site from reading responses from another site, and CORS is how a server grants permission — enforced only in the browser.
The thing to understand first
CORS is not server security. It restricts what browser JavaScript may read.
curl, mobile apps, servers, and scripts ignore it entirely. Never use CORS to protect data — use authentication.
CORS exists to protect users from malicious websites, not to protect your API from attackers.
What an "origin" is
https://app.example.com:443
\___/ \_____________/ \_/
scheme host port -- ALL THREE must match
https://app.example.com vs http://app.example.com DIFFERENT (scheme)
https://app.example.com vs https://api.example.com DIFFERENT (host)
https://app.example.com vs https://app.example.com:8443 DIFFERENT (port)
The two kinds of request
SIMPLE REQUEST -- sent straight away
* method is GET, HEAD, or POST
* AND Content-Type is one of:
text/plain, application/x-www-form-urlencoded, multipart/form-data
* AND no custom headers
-> browser SENDS it, then checks the response headers before letting
your JavaScript READ the result.
(Note: the request already happened. Side effects already occurred.)
PREFLIGHTED REQUEST -- an extra round trip first
* anything else: PUT, DELETE, PATCH
* OR Content-Type: application/json <-- this is the usual trigger
* OR custom headers (Authorization, X-Api-Key, ...)
-> browser first sends:
OPTIONS /resource
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
-> server must reply with matching permissions
-> ONLY THEN does the real request go
Content-Type: application/json is what makes nearly every modern API call
preflighted. That's why "my GET works but my POST doesn't" is the classic CORS
complaint.
The headers the server must send
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true (only if using cookies)
Access-Control-Expose-Headers: X-Request-Id (headers JS may READ)
Access-Control-Max-Age: 86400 (cache the preflight)
Vary: Origin (if you echo the origin back)
The four errors you will actually hit
1. Allow-Origin: * together with credentials.
If the request sends cookies (credentials: 'include'), the wildcard is
forbidden. You must echo the specific origin, add
Access-Control-Allow-Credentials: true, and add Vary: Origin (or a cache
will serve one origin's headers to another).
2. The preflight isn't handled.
Your framework routes PUT /items but returns 404 or 405 for OPTIONS /items.
The real request never happens. Handle OPTIONS explicitly.
3. A custom header isn't listed.
You send X-Request-Id; the preflight response doesn't include it in
Access-Control-Allow-Headers. Blocked.
4. You can't read a response header.
By default JavaScript can only see a small safelist. Anything else needs
Access-Control-Expose-Headers.
And one that wastes hours: an error response still needs CORS headers. If your server throws a 500 without them, the browser reports a CORS error, hiding the real failure. Add the headers in your error handler too.
Practice (25 min)
A. Simulate a preflight from the command line:
curl -sI -X OPTIONS https://httpbin.org/anything \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: content-type' \
| grep -iE 'access-control|^HTTP'
B. A simple request:
curl -sI https://httpbin.org/get -H 'Origin: https://app.example.com' \
| grep -i access-control
C. See it fail and succeed in a browser. Open DevTools console on any site and run:
// this will be BLOCKED by CORS (no permission headers from example.com)
fetch('https://example.com').then(r => r.text()).then(console.log).catch(console.error)
// this will WORK (httpbin sends Access-Control-Allow-Origin: *)
fetch('https://httpbin.org/get').then(r => r.json()).then(console.log)
Read the error message in the console carefully — it tells you exactly which header was missing.
D. Prove that CORS is browser-only: the same https://example.com request
that the browser blocked works perfectly from curl. The data was never
protected; only the browser's JavaScript was restricted.
Common confusions
- "CORS is blocking my server-to-server call." It isn't. Only browsers enforce CORS. If your server call fails, it's a firewall, DNS, or auth problem.
- "I'll just disable CORS." You can't — it's the browser's rule. You can only make the server grant permission. (Browser flags/extensions that disable it are for local debugging only.)
- "A proxy fixes CORS." Yes, and that's a legitimate pattern: your front end calls your own origin, which forwards to the third-party API server-side. Same origin, no CORS involved.
- "Why did it work in Postman?" Postman isn't a browser and doesn't enforce CORS.
Check yourself
- What is an "origin", exactly?
- Why does sending JSON usually trigger a preflight?
- Why can't you use
Access-Control-Allow-Origin: *with cookies? - Does CORS protect your API from an attacker with
curl?
Further reading
- Docs (start here): MDN, "Cross-Origin Resource Sharing (CORS)" — includes a flowchart and a list of every error message with its cause.
- Article: "CORS in 100 Seconds" (Fireship) for a quick video, then MDN for the detail.
- Tool:
test-cors.org— interactively fire cross-origin requests and see what happens.