The course
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
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, mirroringAccess-Control-Request-Headers. -
Access-Control-Allow-Credentials— the single literal tokentrue; its presence lets a credentialed request (cookies, HTTP auth) read the response. It is never the literalfalse— 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.