What does CORS actually protect against, and what's the real difference between simple and preflighted requests?
CORS looks like a firewall rule but it's a browser-enforced contract between origins — knowing what it blocks, and what it never touches, prevents costly misconfigurations.
LEARN BY DOINGPut your intuition to work3 steps
What does CORS actually protect against, and what's the real difference between simple and preflighted requests?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why interviewers ask this
"What is CORS?" filters out candidates who have only ever copy-pasted Access-Control-Allow-Origin: * until the red console error went away. The follow-up senior engineers actually get asked is sharper: what is CORS defending against, and why do some cross-origin calls fire immediately while others trigger an extra round trip first? Getting this wrong in production usually means one of two failure modes — either a real client is blocked because nobody understood which requests need a preflight handler, or a "fix" quietly disables the browser's protection for an endpoint that trusts cookies.
#The rule CORS relaxes, not the rule it creates
The browser's default behavior is the same-origin policy (SOP): a script running on https://evil.example cannot read the response of a request it makes to https://bank.example, even though nothing stops the browser from sending that request in the first place — with bank.example's cookies attached, exactly as if the user had typed the URL themselves. This is the detail most people get backwards: SOP doesn't stop the request from firing, it stops the calling page's script from reading what comes back. CORS is the header-driven protocol a server uses to say "responses to this specific other origin are safe to expose to its JavaScript" — an opt-in relaxation of SOP, not a new restriction layered on top of it.
#Simple requests: sent first, judged after
A cross-origin request is "simple" — sent directly, with no round trip beforehand — only when every one of these holds:
- The method is
GET,HEAD, orPOST. - The only headers present are ones the browser sets automatically, plus a short allow-list (
Accept,Accept-Language,Content-Language, andContent-Typelimited toapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain). - The body isn't a
ReadableStream.
For a simple request the browser attaches an Origin header, sends it immediately, and then checks whether the response's Access-Control-Allow-Origin matches that origin (or is *, when credentials aren't in play). If it doesn't match, the browser discards the response before your JavaScript ever touches it — but by then the request has already run, server-side, in full.
#Preflighted requests: ask before you act
Anything outside that narrow list is "preflighted": PUT, PATCH, DELETE; a custom header like Authorization or X-Request-Id; or — the one that catches almost everyone at least once — Content-Type: application/json, which is not on the simple allow-list. Before sending the real request, the browser automatically issues an OPTIONS request describing what it's about to do, via Access-Control-Request-Method and Access-Control-Request-Headers. The server must answer with matching Access-Control-Allow-* headers, or the browser never sends the real request at all. This is the one case where CORS genuinely blocks the call itself, not merely the reading of its response — which is why almost every JSON API in production needs a correctly configured OPTIONS handler somewhere in its middleware stack.
#Simple vs. preflighted, side by side
| Simple | Preflighted | |
|---|---|---|
| Methods | GET, HEAD, POST | PUT, PATCH, DELETE, or any method with custom headers |
| Content-Type | form-urlencoded, multipart/form-data, text/plain | application/json, application/xml, anything else |
| Custom headers (Authorization, X-*) | Not allowed | Triggers preflight |
| Round trips | 1 | 2 (OPTIONS, then the real request) |
| Can the server block the real call? | Never — only the response read is blocked | Yes — a failed OPTIONS stops the real request |
#The exchange on the wire
# 1) The browser sends this automatically — you never call OPTIONS yourselfOPTIONS /orders HTTP/1.1Host: api.shop.exampleOrigin: https://shop-frontend.exampleAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: authorization, content-type# 2) The server must answer before the browser will send anything realHTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://shop-frontend.exampleAccess-Control-Allow-Methods: GET, POST, PUT, DELETEAccess-Control-Allow-Headers: authorization, content-typeAccess-Control-Allow-Credentials: trueAccess-Control-Max-Age: 600# 3) Only now does the browser send the actual requestPOST /orders HTTP/1.1Host: api.shop.exampleOrigin: https://shop-frontend.exampleAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9...Content-Type: application/json{"sku":"ABC123","qty":2}HTTP/1.1 201 CreatedAccess-Control-Allow-Origin: https://shop-frontend.exampleAccess-Control-Allow-Credentials: trueContent-Type: application/json{"orderId":"o_9f8e7d","status":"pending"}
#Client and server code
// Client — this fetch is preflighted for two independent reasons:// a custom Authorization header, and a JSON Content-Type.async function createOrder(order, token) {const res = await fetch("https://api.shop.example/orders", {method: "POST",credentials: "include", // send cookies cross-origin tooheaders: {"Content-Type": "application/json","Authorization": `Bearer ${token}`,},body: JSON.stringify(order),});if (!res.ok) throw new Error(`Order failed: ${res.status}`);return res.json();}
// Server (Express) — origin allow-list, never a blind reflection of "*"const ALLOWED_ORIGINS = new Set(["https://shop-frontend.example"]);app.use((req, res, next) => {const origin = req.headers.origin;if (origin && ALLOWED_ORIGINS.has(origin)) {res.setHeader("Access-Control-Allow-Origin", origin);res.setHeader("Access-Control-Allow-Credentials", "true");res.setHeader("Vary", "Origin"); // don't let a CDN cache this for the wrong origin}if (req.method === "OPTIONS") {res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");res.setHeader("Access-Control-Allow-Headers", "authorization, content-type");res.setHeader("Access-Control-Max-Age", "600");return res.sendStatus(204);}next();});
🧠 Check your understanding
0/3 · 0/3 answered1. Your frontend at https://app.example makes a cross-origin GET request with only default headers to https://api.example/status, and the server never sets Access-Control-Allow-Origin. What actually happens?
2. Which of these forces a preflighted OPTIONS request?
3. You need cross-origin requests to include cookies (fetch's credentials: 'include'). What must the server avoid?