Topics
Security

CORS: Cross-Origin Resource Sharing

Why the browser blocks a response that curl can read, what a preflight is, how credentials change the rules, and how to configure a server without opening a hole.

Intermediate·10 min read·Updated Sep 27, 2026

Browsers enforce the same-origin policy: a page from one origin cannot read responses from another. CORS is the protocol that lets a server opt out of that restriction for specific origins by answering with Access-Control-* headers. The crucial mechanics: the server usually receives and processes the request anyway; it is the browser that decides whether the calling JavaScript may see the response.

Why it matters

Every frontend developer hits the red "blocked by CORS policy" console line in their first month, and most fixes found online are wrong in one of two directions: either they do not work (adding headers to the request, which the browser ignores), or they work by disabling the protection (Access-Control-Allow-Origin: * everywhere, or reflecting any origin with credentials). Understanding who blocks what and why is the difference between a five-minute fix and a security incident.

What an origin is, and what the browser actually blocks

An origin is the triple scheme + host + port. https://app.example.com and https://api.example.com are different origins, and so are http://localhost:3000 and http://localhost:5173. Subdomains, ports and schemes all count. Paths do not.

For a plain cross-origin GET or form-style POST, the browser sends the request with an Origin header, receives the response, and then checks whether the response carries an Access-Control-Allow-Origin that covers the page's origin. If not, the response is withheld from JavaScript, but the server has already done the work.

Page scripthttps://app.example.comAPI serverhttps://api.example.comGET /data · Origin: https://app.example.com200 OK + body (server did the work)BrowserCORS checkACAO matches →script reads bodyno / wrong ACAOTypeError, body withheld
A simple cross-origin request. The server processes it and responds; the browser inspects the response headers before letting the page's script read it.

Simple requests vs preflights

A request counts as simple when it could have been produced by a plain HTML form or link before CORS existed: method GET, HEAD or POST; only safelisted headers (Accept, Accept-Language, Content-Language, Content-Type); and a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, notably Content-Type: application/json, a PUT/DELETE, or a custom Authorization header, is preflighted.

A preflight is an OPTIONS request the browser sends on its own, before the real one, to ask permission. Only if the server answers with the right headers does the browser send the actual request. The preflight exists so that servers written before CORS never receive an unexpected DELETE from a foreign page.

fetch()PUT + JSON body
OPTIONS /items/7Access-Control-Request-Method: PUT
204Allow-Origin, Allow-Methods, Max-Age
PUT /items/7the real request
Preflight asks (request)Server answers (response)Notes
OriginAccess-Control-Allow-OriginExact origin or *. Never a list.
Access-Control-Request-MethodAccess-Control-Allow-MethodsComma-separated: GET, PUT, DELETE
Access-Control-Request-HeadersAccess-Control-Allow-HeadersMust list Content-Type, Authorization…
—Access-Control-Max-AgeSeconds to cache the preflight result. Chrome caps at 7200.
—Access-Control-Expose-HeadersResponse headers script may read beyond the safelist.

Credentials change the rules

By default a cross-origin fetch sends no cookies and ignores any Set-Cookie in the response. To include them the page must ask with credentials: 'include', and then the server's answer becomes stricter:

  • Access-Control-Allow-Credentials: true is required.
  • Access-Control-Allow-Origin must be the exact origin. * is rejected by the browser for credentialed requests.
  • Allow-Methods and Allow-Headers cannot use * either; they must be listed.

Since browsers made SameSite=Lax the default cookie behaviour (Chrome 80, 2020), a cookie must also be marked SameSite=None; Secure to be sent cross-site at all. A CORS setup that is correct on paper still fails silently if the cookie itself refuses to travel.

client.ts
const res = await fetch('https://api.example.com/me', {
  credentials: 'include',            // send cookies cross-origin
  headers: {Accept: 'application/json'},
});
// Works only if the server echoes the exact origin
// and sets Access-Control-Allow-Credentials: true.

Configuring the server correctly

The safe pattern is an allowlist: compare the incoming Origin against known origins, echo it back only on a match, and always add Vary: Origin so caches key on it. Handle OPTIONS before authentication middleware, because a preflight carries no credentials and a 401 on the preflight fails the whole request.

cors.ts (Express, hand-rolled for clarity)
const ALLOWED = new Set([
  'https://app.example.com',
  'http://localhost:5173',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);   // echo, never *
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');                         // cache per origin
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
    res.setHeader('Access-Control-Max-Age', '600');
    return res.status(204).end();   // before auth — preflights have no cookies
  }
  next();
});

app.use(requireAuth);   // runs only for real requests

Alternatives that avoid CORS entirely

If the frontend and API can share an origin, there is nothing to configure. A reverse proxy that serves /api/* from the same host as the app (Next.js rewrites, Nginx location /api, a Vite dev-server proxy) makes every request same-origin, keeps cookies SameSite=Lax, and removes preflights. For many products this is the better architecture, not a workaround.

Pitfalls

  • Reflecting any Origin with credentials enabled

    res.setHeader('Access-Control-Allow-Origin', req.headers.origin) with Allow-Credentials: true lets any website make authenticated requests as the logged-in user and read the results. It is the credentialed equivalent of *, which the browser forbids for exactly this reason. Always compare against an allowlist.

  • Adding CORS headers to the request

    Access-Control-Allow-Origin is a response header. Setting it on the fetch call does nothing except make the request non-simple, which now triggers a preflight that fails for the same reason. The fix is always on the server.

  • Auth middleware rejecting the preflight

    The browser sends OPTIONS without cookies or Authorization. If the auth layer runs first and returns 401, the preflight fails and the real request is never sent. The console shows a CORS error, not an auth error, which sends people looking in the wrong place.

  • Missing Vary: Origin behind a CDN

    With an allowlist the response differs by origin. A shared cache without Vary: Origin stores the first origin's Allow-Origin and serves it to the second, which then fails intermittently depending on who warmed the cache.

  • Treating CORS as API security

    A strict CORS policy stops browsers on other sites from reading your responses. It does nothing against curl, scripts or a competitor's backend. Authentication, authorization and rate limits still have to exist on the server.

Interview questions

Q1The API works in Postman but the browser shows a CORS error. Did the request reach the server?

For a simple request, yes: the browser sends it, the server processes it, and the browser then refuses to expose the response because Access-Control-Allow-Origin is missing or wrong. For a non-simple request the browser sends an OPTIONS preflight first, and if that fails the real request is never sent. Postman does not implement the same-origin policy, so it never checks.

Q2What makes a request “simple”, and why does the distinction exist?

GET, HEAD or POST with only safelisted headers and one of three form content types. These are requests old HTML could already send cross-origin, so allowing them adds no new attack surface. Anything beyond that, like JSON bodies, PUT/DELETE or custom headers, gets a preflight so servers that predate CORS never see a request they did not opt into.

Q3Walk me through a preflighted request end to end.

The page calls fetch with PUT and a JSON body. The browser sends OPTIONS to the URL with Origin, Access-Control-Request-Method: PUT and Access-Control-Request-Headers: content-type. The server answers 204 with Allow-Origin matching the origin, Allow-Methods including PUT, Allow-Headers including content-type, and optionally Max-Age. The browser caches that, sends the real PUT, and applies the same Allow-Origin check to the final response before handing it to script.

Q4Why can’t you use Access-Control-Allow-Origin: * together with cookies?

Because it would let any site on the internet make requests carrying the user's session and read the responses, which is exactly the cross-site data theft the same-origin policy prevents. The Fetch standard therefore requires an explicit origin plus Allow-Credentials: true, and rejects the wildcard for credentialed requests.

Q5What happens when your CORS allowlist echoes the origin but you forget Vary: Origin behind a CDN?

The CDN caches the first response, including its Allow-Origin header, under the URL alone. A user on a different allowed origin then receives a response allowing someone else's origin and fails the browser check. The bug is intermittent because it depends on which origin populated the cache, which makes it hard to reproduce.

Q6Is CORS a security feature for the server or for the user?

For the user. It stops a malicious page from using the browser, and the user's cookies, to read data from another site. It offers no protection against non-browser clients, so API authentication and authorization are still required. A permissive CORS policy weakens user protection; it does not by itself expose the server.

Key takeaways
  • An origin is scheme + host + port. The browser, not the server, enforces the same-origin policy and CORS.
  • Simple requests are sent and only the response is withheld; non-simple ones get an OPTIONS preflight first.
  • Credentialed requests need an exact Access-Control-Allow-Origin, Allow-Credentials: true, and a SameSite=None cookie.
  • Use an origin allowlist, echo the matching origin, add Vary: Origin, and answer OPTIONS before auth middleware.
  • Never reflect arbitrary origins with credentials, and never treat CORS as authentication.
  • A same-origin reverse proxy removes CORS and preflights entirely and is often the better design.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free