ML
Security

CORS, Demystified: Preflights, Credentials, and the Errors That Aren't

CORS is a browser-enforced rule about who may READ a cross-origin response — not a server firewall. Understanding that flips most CORS errors from mysterious to obvious.

June 12, 202610 min readSecurityWeb

CORS produces some of the most misread errors in web development, because almost everyone starts with the wrong mental model. CORS is not a server-side firewall that blocks requests. It is a browser rule that decides whether JavaScript on one origin is allowed to read a response from another origin. Get that one sentence right and the rest stops being mysterious.

1. What CORS actually is (and isn't)

The browser enforces the same-origin policy: by default, script from https://app.example.com may not read responses from a different origin (different scheme, host, or port). CORS — Cross-Origin Resource Sharing — is the controlled relaxation of that policy: the server opts in by sending Access-Control-Allow-Origin and friends, telling the browser "it's fine to let this origin read my response."

Two consequences people miss:

  • CORS is enforced by the browser, not the server. Your server-to-server calls, curl, Postman, and mobile apps are completely unaffected by CORS — there's no browser to enforce it. If curl works but the browser doesn't, that's CORS, and it's working as designed.
  • The request usually still reaches your server. For a "simple" request the browser sends it, gets the response, and then — if the CORS headers are missing — blocks your JavaScript from reading it. The side effects already happened. This is why CORS is not a security boundary for the server; it protects the user's data in their browser, not your endpoint.

2. Simple requests vs preflight

The browser splits cross-origin requests into two classes. A simple request (roughly: GET/HEAD/POST, only safelisted headers, and a Content-Type of text/plain, application/x-www-form-urlencoded, or multipart/form-data) is sent directly; the browser just checks the response headers afterward.

Anything else — a PUT/DELETE/PATCH, a custom header like Authorization or X-Api-Key, or Content-Type: application/json — triggers a preflight: the browser first sends an OPTIONS request asking permission, and only sends the real request if the server approves.

# Preflight the browser sends automatically:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

# Server must answer with approval:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

The crucial insight: sending JSON (Content-Type: application/json) is enough to trigger a preflight. That's why a request that worked as a form post suddenly "breaks with CORS" when you switch it to JSON — you've moved it from simple to preflighted, and your server isn't answering the OPTIONS. Access-Control-Max-Age lets the browser cache the preflight result so it isn't repeated on every call.

3. Credentials change all the rules

The moment you send cookies or HTTP auth on a cross-origin request (fetch(url, { credentials: 'include' })), the requirements tighten sharply:

  • The server must send Access-Control-Allow-Credentials: true.
  • Access-Control-Allow-Origin may NOT be * when credentials are included — it must echo the specific requesting origin. The wildcard and credentials are mutually exclusive, by spec.
  • Likewise Allow-Headers / Allow-Methods can't be * in credentialed mode; list them explicitly.
Access-Control-Allow-Origin: https://app.example.com   # echoed, never *
Access-Control-Allow-Credentials: true
Vary: Origin                                            # see below

Because you now have to reflect the origin dynamically, you must validate it against an allowlist server-side and add Vary: Origin so caches don't serve one origin's CORS headers to another. Blindly reflecting any Origin back with Allow-Credentials: true is a real vulnerability — it lets any site make credentialed calls as your logged-in user.

4. The errors that aren't what they say

CORS error messages are notoriously misleading. Two in particular:

  • "No 'Access-Control-Allow-Origin' header is present" on a request that returns 500. Often the real problem is that your endpoint threw, and the error path didn't attach CORS headers. The browser then reports CORS, hiding the actual 500. Check the Network tab's response, not just the console.
  • The preflight OPTIONS returns 401/404/405. Many auth middlewares reject the unauthenticated OPTIONS, or the router has no OPTIONS handler. The browser sees a non-2xx preflight and blocks the real request. Your CORS middleware must answer OPTIONS before auth runs and return a 2xx.

A reliable tell: if the failing request never shows up in your server logs but an OPTIONS does (or nothing does), it's a preflight/handler problem, not your business logic.

5. Doing it right on the server

Don't hand-roll header strings. Use your framework's CORS middleware with an explicit allowlist:

// Express example
import cors from 'cors';
const allow = new Set(['https://app.example.com', 'https://admin.example.com']);
app.use(cors({
  origin: (origin, cb) => cb(null, !origin || allow.has(origin)),
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 600,
}));

The middleware handles the OPTIONS preflight, echoes the validated origin, and sets Vary: Origin for you. The two rules to never break: validate the origin against an allowlist (don't reflect arbitrary origins), and never combine * with credentials.

6. When CORS isn't the right tool at all

If a browser app calls many third-party APIs, or you want to avoid exposing keys, a same-origin proxy sidesteps CORS entirely: your frontend calls your own backend (same origin, no CORS), and the backend calls the third party (server-to-server, no browser, no CORS). This is also how you keep secrets out of the client. CORS is for when the browser must talk cross-origin directly and the other server wants to allow it.

Rules of thumb

  • CORS gates READING a cross-origin response in the browser — it's not a server firewall and doesn't protect your endpoint. curl/server-to-server ignore it entirely.
  • JSON bodies, custom headers, and non-simple methods trigger a preflight OPTIONS your server must answer with a 2xx, before auth middleware runs.
  • Credentials forbid *. Echo the specific allowed origin, set Allow-Credentials: true and Vary: Origin, and validate against an allowlist.
  • A CORS error on a 500/401 is often a mask — read the actual response in the Network tab before debugging CORS.
  • Use framework middleware, not hand-written headers, and when the browser doesn't need to call cross-origin directly, a same-origin proxy avoids CORS (and hides your keys).
SharePostLinkedIn

Reader Discussion

2 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    Jun 15, 2026·3 days later
  2. Omar Khalil· Senior SWEKind words

    this is the third article from this blog I've sent to my team this month. you're cooking. don't switch to crypto.

    Jun 17, 2026·5 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email