Frontend Labs
⋯

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

  1. What is an "origin", exactly?
  2. Why does sending JSON usually trigger a preflight?
  3. Why can't you use Access-Control-Allow-Origin: * with cookies?
  4. 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.

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