Skip to content

The publication for web craftspeople Monday, 14 September 2026

Back-end

CORS explained: configuring cross-origin resource sharing without the guesswork

Cross-origin resource sharing stays a stubborn source of confusion in web development. Grasping what the browser protects and which headers the server must return prevents both blocking errors and overly permissive setups.

Cross-origin resource sharing, or CORS, remains one of the most stubborn sources of confusion in web development. Understanding what the browser protects, and which headers the server must return, avoids both blocking errors and dangerously permissive configurations.

What the browser actually protects

By default, a page served from one origin cannot read the response of a request sent to another origin. An origin is three things: the scheme, the host and the port. https://app.exemple.com and https://api.exemple.com are therefore two distinct origins, just as the same host served over HTTP and HTTPS. This rule, the same-origin policy, stops a malicious site from reading data from a service where the victim is authenticated.

CORS does not weaken that protection: it gives the server a way to explicitly allow certain origins to read its responses. The request still goes out, but the browser hides the response from JavaScript until the right headers permit it. That distinction matters: CORS protects the reader, not the server, which must keep its own access controls.

Simple requests and preflighted requests

The browser draws a line between two cases. A so-called simple request (method GET, HEAD or POST, basic headers, a standard content type) goes out directly. As soon as a request steps outside that box, say a PUT, an Authorization header or a JSON body, the browser first sends a preflight request using the OPTIONS method. That step asks the server whether it accepts the call before running it.

OPTIONS /api/orders HTTP/1.1
Host: api.exemple.com
Origin: https://app.exemple.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.exemple.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: Origin

Until the preflight gets a favourable answer, the real request is never sent. A CORS error in the console almost always points to a server answering that OPTIONS badly, not to a client-side bug.

The headers on the server side

The CORS contract fits in a handful of response headers. Knowing each one clears up most blockages.

HeaderRole
Access-Control-Allow-Originallowed origin, a single value or *
Access-Control-Allow-Methodsaccepted methods, in response to the preflight
Access-Control-Allow-Headersallowed request headers
Access-Control-Allow-Credentialsallows cookies and credentials, incompatible with *
Access-Control-Max-Agehow long the preflight is cached, in seconds
Access-Control-Expose-Headersresponse headers readable by JavaScript

Configuring it per stack

The logic is the same everywhere: recognise the calling origin, compare it against an allow-list, then return the matching headers. On Node, a single middleware does the job.

const ALLOWED = new Set([
  "https://app.exemple.com",
  "https://admin.exemple.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "600");
    return res.status(204).end();
  }
  next();
});

The same contract can live at the front-end server, which avoids loading the application for plain OPTIONS requests.

location /api/ {
    set $cors "";
    if ($http_origin ~* ^https://(app|admin)\.exemple\.com$) {
        set $cors $http_origin;
    }
    add_header Access-Control-Allow-Origin $cors always;
    add_header Vary Origin always;

    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 600 always;
        return 204;
    }
    proxy_pass http://backend;
}

Cookies, credentials and classic traps

The most common trap pairs Access-Control-Allow-Origin: * with sending cookies. The specification forbids it: as soon as the request carries credentials, the origin must be returned by name and Access-Control-Allow-Credentials set to true. Reflecting the received origin dynamically then requires adding Vary: Origin, otherwise a cache could serve the wrong permission to another origin.

A star that allows everyone and cookies in the same response: the specification refuses, and that is good news.

Two other confusions recur. A correct HTTP error code with missing CORS headers shows up in the console as a CORS error, even though the server did answer. And JavaScript can only read, on a cross-origin response, the headers declared in Access-Control-Expose-Headers, a detail that often blocks reading a pagination header or a token.

Rebuilding the Access-Control-Allow-Origin header from the received origin without validating it against an allow-list amounts to allowing everything. An over-broad regular expression, such as exemple.com without anchoring, lets exemple.com.attacker.net through. An explicit allow-list is the only safe approach.

Test and debug without guessing

Reproducing the browser’s behaviour on the command line isolates the cause at once. An OPTIONS call forged with curl shows whether the server returns the right preflight headers, before you even open the browser.

# Reproduce the preflight the browser would send
curl -i -X OPTIONS https://api.exemple.com/api/orders \
  -H "Origin: https://app.exemple.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type, authorization"

# Then replay the real request and inspect its CORS headers
curl -i https://api.exemple.com/api/orders \
  -H "Origin: https://app.exemple.com"

In the network tab of the developer tools, a request blocked by CORS shows a status but an unreadable response, while the console spells out the missing header. That pair, a console message then a curl replay, is enough to decide within seconds between a server configuration error and a genuine application fault. A preflight that answers correctly in curl but fails in the browser almost always points to an intermediary, a proxy or a CDN, filtering the OPTIONS method or rewriting headers.

Subdomains, environments and cookies

On top of the credentials question sits the cookies’ SameSite attribute, independent of CORS yet often confused with it. A cookie set to SameSite=Lax will not be sent on a background request to another site, even with a perfect CORS setup. Shared authentication across distinct subdomains therefore requires both a SameSite=None; Secure cookie and credentials allowed on the server side. When several environments coexist, staging and production on neighbouring subdomains, the allow-list is better derived from an environment variable than hard-coded, so a staging URL never leaks into production.

What to remember

CORS is not an obstacle to work around but a contract to honour. The browser enforces the same-origin policy; the server knowingly chooses which origins may read its responses. A strict allow-list, a correctly handled preflight, a Vary: Origin when the origin is dynamic, and the vast majority of errors vanish. The temptation of the * wildcard is justified only for genuinely public, credential-free resources. For everything else, naming origins costs a few lines and protects for the long run.

On my projects, the first question when a request breaks in production is no longer “where is this CORS bug coming from?” but “is the server answering the OPTIONS correctly?”. Nine times out of ten, the answer is there. I ended up banning * from my authenticated APIs and centralising the allow-list in a single constant, re-read at every review. It is less spectacular than a clever fix, but it saves me from reopening the same ticket every quarter. — Simon Janvier

Further reading: the CORS reference on MDN Web Docs and the WHATWG Fetch specification.

Read next