The headers

CORS is a short conversation in HTTP headers. The browser attaches a few request headers to tell the server what it's trying to do, and the server answers with a few response headers that say whether — and how — the page may read the result. There are only a handful of each, and once you can read them the whole mechanism stops being magic. The playground below is the open console for exactly that: every server-side header and every client-side fetch option is an independent knob, so you can compose any combination and watch the real request and the browser's verdict play out.

Who enforces this, and against whom? As always, it is the browser that enforces CORS — to protect its own user, not the server. The server only opts in by sending headers; tools like curl, server-to-server calls, and native apps ignore the conversation entirely. And the gate the browser keeps is between the response and your page's JavaScript: a "blocked" response still arrived at the browser in full, visible in the Network tab — CORS decides whether the browser hands it to your code. The rules are defined by the WHATWG Fetch standard (there is no separate "CORS RFC"); the notion of an origin itself comes from RFC 6454.

Compose a request, watch the headers play out

Server response headers
Your request

Compose a configuration above and press Run this configuration to fire a real cross-origin request and watch how the headers you chose play out.

What the browser sends

Three request headers carry the CORS conversation, and only on the right kinds of request:

  • Origin — attached to every cross-origin request the browser makes on a page's behalf (scheme + host + port, never the path). This is the value the server compares against its allowlist when it decides whether to opt in.
  • Access-Control-Request-Method — sent only on a preflight, naming the method the real request wants to use, so the server can approve it in advance.
  • Access-Control-Request-Headers — also preflight-only, listing the non-safelisted headers the real request wants to carry, so the server can approve those too.

A "simple" request — a safelisted method with no non-safelisted headers — sends only Origin. The other two appear exclusively on the OPTIONS preflight a browser sends before a non-simple request. In the playground above, switch Content-Type to application/json and watch a preflight appear: the browser starts asking permission before it sends your request.

What the server can send back

The response headers are the server's opt-in. Each one is a knob in the playground, because each one governs a distinct part of the decision:

  • Access-Control-Allow-Origin — the one header that decides a simple request. It carries either a single reflecting origin (the exact string the page's origin is) or the literal * wildcard. Absent, and the read is blocked.
  • Access-Control-Allow-Methods — preflight-only; the list of methods the server permits. If the real request's method isn't in it, the browser never sends the real request.
  • Access-Control-Allow-Headers — preflight-only; the request headers the server permits, mirroring Access-Control-Request-Headers.
  • Access-Control-Allow-Credentials — the single literal token true; its presence lets a credentialed request (cookies, HTTP auth) read the response. It is never the literal false — absence is how "no" is said.
  • Access-Control-Max-Age — preflight-only; how many seconds the browser may cache the preflight's answer, so it doesn't re-ask before every request. It is a performance header, not a security one.
  • Access-Control-Expose-Headers — actual-response-only; which response headers JavaScript is allowed to read. See the next section — it is the quietest trap in the whole mechanism.

Access-Control-Expose-Headers: the invisible response header

A response header can be fully delivered to the browser and still invisible to your code. By default, cross-origin JavaScript can read only a small CORS-safelisted set of response headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma); every other header returns null from response.headers.get() unless the server lists it in Access-Control-Expose-Headers. The header is genuinely on the response — you can see it in the Network tab — it is simply withheld from your page. This is the same "the browser received it, your code didn't" split that defines CORS itself, applied one layer deeper.

Try it in the playground: leave the configuration readable and toggle Access-Control-Expose-Headers between omitted and list X-Corslabs-Demo. The response carries X-Corslabs-Demo either way, but only when it is listed does JavaScript see anything but null. A custom header your code silently can't read, with no error thrown, is one of the most common real-world CORS confusions — and it isn't even a block.

Origins must match byte-for-byte

Access-Control-Allow-Origin is compared against the request's Origin as an exact string. There is no normalization, no trimming, no "close enough": a same-origin-looking value that isn't byte-identical simply does not match. The origin is the scheme, host, and port together — nothing else — and every character counts.

Concretely, against a requesting origin of https://www.example.com:

  • Access-Control-Allow-Origin: https://www.example.com — matches. (right)
  • Access-Control-Allow-Origin: https://www.example.com/ — does not match. A trailing slash is a path, and the origin has no path. (wrong)
  • Access-Control-Allow-Origin: https://www.example.com:443 — does not match under the Fetch rules. The default port is implicit; making it explicit is a different string. (wrong)
  • Access-Control-Allow-Origin: HTTPS://www.example.com — does not match. The scheme is compared case-sensitively. (wrong)

This is the exact trap behind a huge class of "but it looks right" CORS failures: the server meant to allow the origin, but emitted a string that isn't the one the browser sent. The playground's reflect this origin option always emits the exact bytes of the allowlisted origin the browser sent, which is the only generally-correct way to allow a specific origin — never hand-assemble the string.

Watching in the browser's developer tools? The playground fires real cross-origin requests at the live demo API, so the Network tab is the honest source of truth: the request headers the browser attached, the response headers the server sent, and — when something didn't add up — the console's CORS error pointing at the specific header. The Inspector above mirrors those same facts from the server's own recorded account, so you can line the two views up. Note that a preflight's response headers are themselves never readable from JavaScript; the verdict trail is derived from the demo API's documented contract, while the "what the server sent back" bubbles show the real recorded headers over the report channel.