skip to content

CORS

The browser's controlled exception to the same-origin rule: the preflight that asks, the Access-Control headers that answer, and what credentials change. Most explanations of it are wrong.

part ofWeb protocols & securityoverview, primer and where to startread it →
on this pageshow

questions

27

Why does a cross-origin request the browser blocks reach the calling script with no status code and no body?

level: juniorimportance: must knowfreq 66%

answer

  1. two events, one response
  2. the server logged it; script did not
  3. the check runs after the bytes arrive
  4. network error, not an HTTP error
  5. status 0, empty headers, null body

basics

~20 s

A failed CORS check replaces the server's response with a network error before script sees it - type "error", status 0, an empty header list, a null body - so the call rejects with a bare TypeError that carries no protocol detail at all.

solid answer

~40 s

Two separate things happen. The server received the request, produced a response and logged it; the browser then ran the **CORS check** on a response it already held in full, found no grant naming the calling origin, and refused to hand it over. What it substitutes is a **network error**: response type `"error"`, status `0`, an empty header list and a null body. A call that lands on one does not resolve with something to inspect - it rejects, and the rejection is a bare `TypeError`. That opacity is deliberate: releasing the status or the headers would leak precisely the cross-origin information the check exists to withhold. The practical consequence is that the error object is not evidence, and the two wire exchanges are.

code

http · 10 lines
http
GET /picks/queue?station=17 HTTP/1.1
Host: api.example.net
Origin: https://console.example.net
Accept: */*

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 47

{"station":17,"picks":[{"bin":"A-14","qty":3}]}

go deeper

for a junior

Recall the shape: a blocked cross-origin read becomes a network error with status 0, no headers and no body, surfaced as a bare TypeError. Knowing it carries nothing is most of the answer.

for a middle

Explain the ordering - the response arrived in full and was logged, then the check ran and the browser substituted the network error. Name which side enforces and which side grants.

for a senior

Show that you treat the error object as worthless evidence and go straight to the two wire exchanges and the server's own log, rather than instrumenting the calling code further.

for a principal

Frame the cost: teams burn days here because the failure is designed to be uninformative, so the leverage is in making the grant a reviewable property of each service rather than a thing rediscovered per incident.

## Two different things happened to one response A cross-origin call that "fails" has usually not failed once. Two independent things happened, and keeping them apart is the whole of this leaf: 1. **The server received a request, produced a response, and logged it.** Nothing about the caller being on another origin changes that. The request travelled, the handler ran, the bytes came back, and the access log recorded whatever status the handler produced - very often `200`. 2. **The browser then ran the CORS check on a response it had already received in full.** The check asks one question: does this response carry a grant naming the origin that called? If it does not, the browser does not hand the response to the script. The direction matters and is the most common thing stated backwards. The **browser enforces**; the **server only grants**. A server does not block a cross-origin read - it simply fails to state that one is permitted, and the browser withholds what it is already holding. ## What the script is actually handed When the check fails, the browser does not pass the response through with a marker on it. It substitutes a **network error**, which is a response with a fixed and deliberately empty shape: - its **type** is `"error"`; - its **status** is `0`, because no status line survived into it; - its **header list** is **empty** - not one response header is readable, not even the ones script may normally read; - its **body** is **null**. A call that lands on a network error does not resolve with an object to inspect. It **rejects**, and what it rejects with is a bare `TypeError`: an error object carrying a message and no protocol data whatsoever. There is no status on it, no header collection, no payload, and no field naming which rule was violated. ## An HTTP error you can read versus a failure you cannot | what arrived | status visible to script | headers | body | can script branch on it? | |---|---|---|---|---| | an HTTP error the server returned **and** the browser released | the real one, e.g. `403` | readable | readable | yes | | a network error from a failed CORS check | `0` | empty | null | no | The asymmetry surprises people the first time: a `403` the browser is permitted to release is far more informative than a `200` it is not. "The server returned an error" and "the browser withheld a success" look identical from the calling code, and only the second one leaves a clean green line in the server's log. ## Why the browser refuses to say more The opacity is the feature, not an oversight. The rule the check enforces is about **reading**, not about sending: a cross-origin request has always been allowed to leave, and what is controlled is whether the answer may be read. If the failure told the caller the status, a hostile page could learn from the status alone whether an internal host exists, whether a path is present, or whether the visitor is signed in somewhere - all without ever reading a byte of the body. Handing back a detailed error would re-open the exact channel the check closes, so the failure is flattened to one indistinguishable shape. ## What this means when you are diagnosing - **Treat the error object as "did not complete readably" and nothing more.** It cannot tell you whether the host resolved, whether the connection was refused, whether the answer was `200` or `500`, or which condition failed. - **The server's access log is one half of the evidence.** A green `200` there is entirely compatible with the page reading nothing at all. - **The wire exchange is the other half**, and it is the only half that shows the response headers - which is where the grant either is or is not. - **The fix lives in the response.** A grant is something a server states about a caller's origin; it is not something a caller can assert about itself, so no amount of work in the error handler produces one. - **Resist the first reflex on the server, too**, which is to widen the grant to the broadest possible value. That is a decision about who may read the API, and it does not belong in a debugging session. ## The shape of a real investigation Because the error carries nothing, the investigation has to move to where evidence exists: reproduce the exchange outside the browser, look at the response headers the server really emits for that URL, and check them against the origin the page really sends. The error object never becomes more informative than it was in the first second.

  • Why is a 403 the browser releases more useful to you than a 200 it withholds?
    Because the `403` arrives intact: its status line and headers are readable, so the calling code can branch on it and a human can see what the server decided. The withheld `200` is flattened into a network error with status `0` and an empty header list, which says only that nothing readable arrived.
  • If the rejection carries nothing, where can a fix for this even live?
    In the response. A grant is a statement a server makes about the calling origin, and it travels as a response header on the exchange the browser judges. That is why diagnosis moves to the two wire exchanges rather than to the error handler: the error handler will never have more to work with than it does now.

A parcel is delivered to the building and signed for at the door. The doorman then decides you are not on the list for it and destroys it, telling you only that nothing came - while the sender's records still show a completed delivery.

saying these in an interview costs you the question

  • Says the server refused the request because of CORS.
  • Expects the rejection object to carry the response status code.
  • Reads status 0 as proof the host was unreachable.
  • Assumes an empty body means the server returned nothing.
  • Treats the error object as diagnostic evidence about the server.
open as a page

A cross-origin lookup shows an OPTIONS request on the wire before the real one - what does the browser put in it?

level: juniorimportance: must knowfreq 72%

basics

~10 s

A CORS-preflight request: method OPTIONS, the calling page's Origin, Access-Control-Request-Method naming the method the real call will use, Access-Control-Request-Headers listing any CORS-unsafe request-header names, and Accept: /. It carries no body.

open as a page

A cross-origin response carries a custom X-Gauge-Reported-At header that script cannot read - which response field makes it readable?

level: juniorimportance: must knowfreq 58%

basics

~10 s

Access-Control-Expose-Headers, sent by the server on that response, names the extra fields script may read. Without it a cross-origin caller sees only seven names: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma.

open as a page

What exact value does a browser put in the `Origin` request header for a page at `https://board.example.org/embed/arrivals?stop=42`?

level: juniorimportance: must knowfreq 72%

basics

~10 s

The Origin request header carries only scheme, host and a non-default port: https://board.example.org. The path, the query, the fragment and any trailing slash are absent, and scheme and host are ASCII-lower-cased.

open as a page

Your picking API answers its OPTIONS preflight with 401 - why can that cross-origin call never succeed?

level: middleimportance: must knowfreq 57%

basics

~20 s

For two independent reasons. The preflight is sent in credentials mode "same-origin", so cross-origin it carries no cookie and no authentication entry and cannot meet the WWW-Authenticate challenge; and 401 is not an ok status, so the preflight fails regardless.

open as a page

Why is copying the incoming `Origin` request header into `Access-Control-Allow-Origin` not a dynamic allow-list?

level: middleimportance: must knowfreq 68%

basics

~20 s

Reflection admits every origin rather than a list of them. The server compares the arriving value with nothing, so an attacker's page receives a grant identical to a partner's, and script on that page reads the response body.

open as a page

Why does the OPTIONS preflight for a credentialed cross-origin call reach your API with no cookie attached?

level: middleimportance: must knowfreq 52%

basics

~20 s

Because the preflight is a separate request the browser constructs with credentials mode "same-origin", and the target is a different origin. Nothing ambient is attached to it, whatever credentials mode the real request will use.

open as a page

Which values may Access-Control-Allow-Origin carry in one response, and why can it never list three allowed hosts?

level: middleimportance: must knowfreq 72%

basics

~20 s

Access-Control-Allow-Origin carries exactly one value: one serialized origin, the literal null, or the wildcard. Its grammar defines no list, so a feed serving several caller origins emits the one value that fits the current request rather than enumerating them.

open as a page

An `Origin` header arrives as `https://board.example.org` — why does a configured entry written `https://board.example.org/` never byte-match it?

level: middleimportance: must knowfreq 58%

basics

~20 s

The arriving value is a serialized origin, which has no path component and therefore no trailing slash. The entry is one byte longer, so an equality test over bytes fails even though a human reads the two strings as the same site.

open as a page

Why must a preflight response carry `Access-Control-Allow-Credentials: true` when the preflight itself never carries a credential?

level: seniorimportance: must knowfreq 60%

basics

~20 s

Because the preflight's response is CORS-checked against the original request, whose credentials mode is "include" — so the server must declare that it accepts credentialed reads on a leg where it has not yet seen a credential.

open as a page

When does `Access-Control-Allow-Origin: *` leak data, given the same header reveals nothing on a fully public resource?

level: seniorimportance: must knowfreq 60%

basics

~20 s

It leaks whenever the resource's only protection is the caller's network position. On data any client could already retrieve anonymously the wildcard reveals nothing new; on an internal service it converts every employee's browser into a reader on the attacker's behalf.

open as a page

In the CORS protocol, what counts as a credential on a cross-origin request besides the session cookie?

level: juniorimportance: should knowfreq 45%

basics

~20 s

CORS counts three kinds of ambient client state as credentials: HTTP cookies, TLS client certificates, and cached HTTP authentication entries. All three travel without the calling code doing anything, which is why one grant field governs all of them.

open as a page

What does a server grant when it answers a credentialed cross-origin request with `Access-Control-Allow-Credentials: True`?

level: middleimportance: should knowfreq 40%

basics

~20 s

Nothing at all. The field's value is defined as the byte sequence true, case-sensitively, so True is not a grant; the browser behaves exactly as if the field were absent and refuses script the response.

open as a page

Why can an OPTIONS preflight answered with a 3xx redirect never authorise the request that would follow it?

level: middleimportance: should knowfreq 43%

basics

~20 s

Because a preflight's own answer is judged where it stands: the browser does not chase the redirect and judge the second response instead, and a redirect status is not an ok status, so the preflight fails and the real request is never sent.

open as a page

Your API answers preflights with Access-Control-Max-Age: 600, yet OPTIONS requests keep appearing - what does that field cache?

level: middleimportance: should knowfreq 48%

basics

~20 s

Access-Control-Max-Age is a delta-seconds hint that populates the browser's own CORS-preflight cache. Entries are per method and per header name, not one per URL, so a new method or a newly named header preflights again.

open as a page

A preflight answer lists only PUT in Access-Control-Allow-Methods; which cross-origin methods may the caller still attempt?

level: middleimportance: should knowfreq 48%

basics

~20 s

GET, HEAD and POST remain available: they are CORS-safelisted methods and pass the check without appearing in the grant. Access-Control-Allow-Methods is a comma-separated list that adds methods beyond those three, so listing PUT removes nothing.

open as a page

Calls from a warehouse wall console to its picking API broke overnight - how do you reproduce and judge the preflight outside the browser?

level: seniorimportance: should knowfreq 51%

basics

~20 s

Re-issue the OPTIONS by hand to the exact URL the page calls, carrying the page's exact Origin and an Access-Control-Request-Method naming the real method, then judge the answer against two conditions at once: an ok status, and a CORS check that passes.

open as a page

A service allows any `Origin` whose host ends in its own domain name. How does an attacker-registered host pass that check?

level: seniorimportance: should knowfreq 52%

basics

~20 s

Ending in a name is not being it. The attacker registers a domain whose name ends with the allowed string, such as evilconf-schedule.example against an allowed conf-schedule.example, so the suffix test passes and the grant is issued to them.

open as a page

A feed answers Access-Control-Allow-Headers with the wildcard, yet a caller sending Authorization still fails the check - why?

level: seniorimportance: should knowfreq 40%

basics

~10 s

Authorization is the CORS non-wildcard request-header name: the wildcard in Access-Control-Allow-Headers covers other unsafe request headers but never that one, so a server must list Authorization by name for a caller that sends it.

open as a page

Why does the departures host see an `Origin` header on a request the browser sent to that same host, and what does its presence prove?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Two independent triggers attach the header: a request whose response tainting is cors, and a request whose method is neither GET nor HEAD. The second fires even when the destination is the sending host itself, so presence proves nothing about direction — only the value does.

open as a page

Dozens of services each carry their own CORS allow-list, edited by whoever last needed a partner unblocked. How would you make that grant surface governable?

level: principalimportance: should knowfreq 38%

basics

~20 s

Make the allowed set declared data with an owner, a reason and an expiry per entry, consumed by services rather than authored in them, and prove the estate's behaviour by sending unrelated origins at every route rather than by reading configuration.

open as a page

Under credentialed mode, what does `Access-Control-Expose-Headers: *` make readable to the calling script?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Only a response header whose name is literally *. Once the request's credentials mode is "include", the wildcard in the grant fields stops being a wildcard and is matched as an ordinary field name, so nothing extra is exposed.

open as a page

A service answers `Access-Control-Allow-Origin: null` so a sandboxed preview can call it. Who has just been granted read access?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Every context whose origin serializes to that value, all at once. The token names no particular party, so it cannot identify the preview it was added for, and a document can be placed in such a context deliberately.

open as a page

A cross-origin GET carrying only safelisted request-header names started preflighting once one value grew long - which rule fired?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Safelisting is limited by size as well as by name: a value longer than 128 bytes disqualifies that field, and once the safelisted values total more than 1024 bytes all of them count as CORS-unsafe request-header names, so a preflight is sent.

open as a page

An `Origin` header arrives as the literal `null` after a request followed a redirect across hosts — what produced that value?

level: seniorimportance: nice to knowfreq 32%

basics

~20 s

Once a request's redirect chain crosses an origin boundary, the request's origin is treated as tainted and serializes to the literal four-byte value null instead of the caller's origin. The final host therefore receives no usable caller identity.

open as a page