What is CORS?

This page documents general Cross-Origin Resource Sharing (CORS) behavior as specified by the WHATWG Fetch Standard. Unlike the other reference sections on this site, no single reference book underpins it: the content was generated with the assistance of AI from general knowledge, and should be verified against the Fetch Standard’s CORS protocol section and MDN’s CORS guide before relying on it in production.

Cross-Origin Resource Sharing (CORS) is a browser mechanism that relaxes the same-origin policy, at the server’s discretion, to let a web page make requests to a different origin than the one it was served from. This page covers what CORS actually protects, how the browser and server negotiate it, and how to validate origins correctly on the backend.

What CORS Is

CORS is enforced only by the browser. The browser’s default is to block a script running on one origin from reading the response of a request it makes to a different origin — the same-origin policy. Two URLs share an origin only when their protocol, hostname, and port all match; https://example.com and http://example.com are different origins (different protocol), as are https://example.com and https://api.example.com (different hostname), and https://example.com and https://example.com:8443 (different port). CORS is the server’s way of opting specific other origins back in.

Because CORS is enforced client-side, it is not a server-side security measure. It does not protect an API from being called directly — a request made with curl, Postman, a mobile app’s HTTP client, or a script running server-side never goes through a browser, so there is no same-origin policy to relax or CORS check to perform in the first place. Anything an API exposes to a browser over CORS is just as reachable without it; authentication and authorization are what actually gate access (see CORS Does Not Replace Authentication).

The Origin Header

When a page’s script issues a cross-origin request, the browser automatically attaches an Origin header of the form Origin: <scheme>://<host>[:<port>], e.g. Origin: https://app.example.com. This header:

  • Is set by the browser itself, not by page JavaScript — a script cannot spoof or omit it.

  • Carries only the requesting page’s origin (never a full path), which is exactly the information the server needs to decide whether to allow the request.

  • Is present on cross-origin requests, and on same-origin POST/PUT/DELETE/PATCH requests in most modern browsers, but not on same-origin GET/HEAD requests.

The server inspects Origin and decides, per request, whether to answer with CORS headers that allow the calling origin to read the response.

Simple vs. Preflight Requests

Not every cross-origin request is treated the same way. A request qualifies as a simple request — sent directly, with no preliminary check — only when all of the following hold:

  • The method is GET, HEAD, or POST.

  • Only a small set of "CORS-safelisted" headers are set (e.g. Accept, Accept-Language, Content-Language, Content-Type with a restricted value).

  • When present, Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Anything else — PUT, DELETE, PATCH, Content-Type: application/json, or a custom header such as Authorization or X-Requested-With — is not simple, and triggers a preflight: the browser first sends an OPTIONS request to ask the server for permission, before sending the actual request at all.

For a simple request, the browser sends the request immediately and simply checks the response headers before letting the script read the body:

sequenceDiagram participant Browser participant Server Browser->>Server: GET /api/data (Origin: https://app.example.com) Server-->>Browser: 200 OK + Access-Control-Allow-Origin: https://app.example.com Note over Browser: Origin allowed -- response body exposed to script

For a non-simple request, the browser performs the OPTIONS handshake first, and only sends the real request if the server approves it:

sequenceDiagram participant Browser participant Server Browser->>Server: OPTIONS /api/data
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization Server-->>Browser: 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: content-type, authorization Note over Browser: Preflight approved Browser->>Server: PUT /api/data (Origin: https://app.example.com) Server-->>Browser: 200 OK + Access-Control-Allow-Origin: https://app.example.com Note over Browser: Origin allowed -- response body exposed to script

The preflight OPTIONS request carries Access-Control-Request-Method (the method the real request will use) and, if any, Access-Control-Request-Headers (the custom headers it will send), so the server can approve or reject the real request before it ever runs.

Server Response Headers

The server communicates what it allows through a small set of Access-Control-* response headers:

Header Meaning

Access-Control-Allow-Origin

The origin (or ) allowed to read the response. Must echo back the specific requesting origin — not  — whenever credentials are involved (see below).

Access-Control-Allow-Methods

Sent on the preflight response; the HTTP methods the server permits for this resource.

Access-Control-Allow-Headers

Sent on the preflight response; the request headers the server permits beyond the CORS-safelisted set.

Access-Control-Allow-Credentials

Set to true to allow the browser to expose the response when the request was sent with credentials (cookies, HTTP auth) — see below.

A subtlety worth internalizing: for a simple request, the actual request still runs on the server — CORS headers do not stop it from executing — CORS only controls whether the browser lets the page’s script read the response. A POST that mutates data still mutates it even if the browser then blocks the script from seeing the result, which is exactly why CORS is not a substitute for real authorization. A preflighted (non-simple) request is the exception: if the OPTIONS preflight is rejected, the browser never sends the real request at all.

Validating Origins on the Backend

The safe pattern is an explicit allowlist, checked against the incoming Origin header, with the response varying per request:

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

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }

  // Tell caches/CDNs the response varies by request origin -- otherwise a
  // cached response for one origin could be served to a different one.
  res.setHeader("Vary", "Origin");

  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    return res.status(204).end();
  }

  next();
}

This pattern is language/framework-agnostic — an equivalent allowlist check applies whether it is expressed as Express middleware, a Django/DRF setting, a Spring CorsConfigurationSource bean, or any other backend’s CORS configuration. The essentials are always the same: compare Origin against a known-good set (or a correctly anchored pattern), echo back only that exact origin, and set Vary: Origin so shared caches don’t leak one origin’s allowed response to another.

Common Mistakes

  • Access-Control-Allow-Origin: combined with Access-Control-Allow-Credentials: true. Browsers reject this combination outright — a wildcard origin can never be paired with credentials. If it *did work, it would let any website read a logged-in user’s authenticated responses (cookies included) from the API, which is exactly the cross-site data theft the same-origin policy exists to prevent.

  • Reflecting the received Origin header back unvalidated. Setting Access-Control-Allow-Origin: <whatever Origin the request sent> without checking it against an allowlist is functionally equivalent to allowing every origin, while looking like a real check.

  • An unanchored allowlist regex. A pattern like /example\.com/ (no ^/$ anchors at all) matches far more than intended — it only checks that example.com appears somewhere in the string, so both evil-example.com and example.com.attacker.com slip through. Match the full origin string exactly (^https://example\.com$), or maintain an explicit set as in the example above.

  • Relying on Referer instead of Origin for validation. Referer carries the full requesting URL (not just the origin), is not sent on every request (privacy settings, Referrer-Policy, and some proxies can strip or omit it), and was never designed as an access-control signal. Origin is purpose-built for this check and is what browsers themselves rely on.

CORS Does Not Replace Authentication

CORS is a browser-side access-control layer that governs whether a script running on one origin may read a response from another — nothing more. It says nothing about who is making the request or whether they are allowed to perform it. Real access control still has to come from actual authentication and authorization: session cookies, bearer tokens, API keys, and the server-side checks built on top of them. Treat CORS configuration as a browser-compatibility concern for legitimate cross-origin clients, never as a substitute for verifying who is calling the API.