CORS Deep Dive

intermediate cors security same-origin preflight browser

CORS (Cross-Origin Resource Sharing) is the browser mechanism that decides whether JavaScript on origin A is allowed to read responses from origin B.

In simple language: by default the browser blocks cross-origin reads. CORS is the server saying “yes, this origin is allowed.”

Same-Origin Policy

Two URLs are the same origin if all three match:

  • Scheme (https)
  • Host (api.example.com)
  • Port (443)
URL AURL BSame origin?
https://example.comhttps://example.com/pathyes
https://example.comhttp://example.comno (scheme)
https://example.comhttps://api.example.comno (host)
https://example.com:443https://example.com:8443no (port)

The same-origin policy is enforced by the browser, not the server. From a server’s view, a request from app.example.com looks identical to one from evil.com. The browser is the one refusing to expose the response to JS unless CORS headers say it’s okay.

Important: this only applies to JavaScript-initiated requests. <img>, <script>, <link> tags can load cross-origin resources freely (just JS can’t read their bytes).

Simple vs Preflight Requests

CORS splits requests into two buckets.

Simple requests

Sent directly. The browser checks the response headers afterwards to decide whether to expose it to JS. A request is “simple” only if all of these are true:

  • Method is GET, HEAD, or POST.
  • Headers are limited to a small CORS-safe list (Accept, Accept-Language, Content-Language, Content-Type).
  • Content-Type is one of text/plain, application/x-www-form-urlencoded, multipart/form-data.

Preflighted requests

For anything else (custom headers, PUT/DELETE, JSON Content-Type), the browser sends an OPTIONS preflight first to ask for permission.

Browser                                  Server
   │                                        │
   │── OPTIONS /api/users ─────────────────>│   "Can I send a PUT with
   │   Origin: https://app.example.com      │    Authorization header?"
   │   Access-Control-Request-Method: PUT   │
   │   Access-Control-Request-Headers: ...  │
   │                                        │
   │<── 204 No Content ─────────────────────│   "Yes, allowed"
   │   Access-Control-Allow-Origin: ...     │
   │   Access-Control-Allow-Methods: ...    │
   │                                        │
   │── PUT /api/users ─────────────────────>│   actual request
   │<── 200 OK ─────────────────────────────│

The preflight is the OPTIONS round-trip. If it fails, the real request is never sent.

Key Response Headers

  • Access-Control-Allow-Origin — the origin that’s allowed. Either an exact match (https://app.example.com) or *. With credentials, * is forbidden.
  • Access-Control-Allow-Methods — methods the server accepts (GET, POST, PUT, DELETE).
  • Access-Control-Allow-Headers — headers the client may send (Authorization, Content-Type, X-Request-Id).
  • Access-Control-Allow-Credentialstrue if cookies / Authorization should be sent on cross-origin requests.
  • Access-Control-Max-Age — how long the browser may cache the preflight result (in seconds). High values mean fewer OPTIONS calls.
  • Access-Control-Expose-Headers — extra response headers that JS is allowed to read (default exposed list is tiny).

A Working Server (Express)

import express from "express";
const app = express();

app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowed = ["https://app.example.com", "https://admin.example.com"];

  if (allowed.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin"); // important for caches
  }
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
  res.setHeader("Access-Control-Max-Age", "86400");

  if (req.method === "OPTIONS") return res.status(204).end();
  next();
});

Common Gotchas

  • * with credentials is illegal. If we want cookies/Authorization, we must echo back a specific origin. Use a whitelist.
  • Always Vary: Origin when origin is dynamic. Otherwise CDNs cache one origin’s allow header and serve it to everyone.
  • CORS errors are not the server’s status code. A 403 from the API is a real response. A CORS failure shows up only in the browser console — the network tab might show “(failed) net::ERR_FAILED” or a successful OPTIONS followed by a blocked main request.
  • Authorization header triggers preflight. Adding a JWT to a GET makes it non-simple.
  • fetch with credentials: "include" is the only way cookies cross origins. same-origin (default) won’t send them.
  • Server didn’t break — the browser blocked it. The server processed the request normally and may have logged it. CORS only stops JS from reading the response.

Interview Tip

If asked “explain CORS in one minute”: (1) browsers enforce the same-origin policy, (2) servers opt in via Access-Control-* headers, (3) “non-simple” requests get a preflight OPTIONS first. Bonus points for naming the four parts that decide simple vs preflight (method, headers, content-type, no streams), and for mentioning that CORS protects users from malicious sites — not servers from malicious clients.