Web Platform · Web Security Model

CORS Preflight, Credentials, and the Response Sharing Rules

The request classifications that decide whether a preflight happens, what the OPTIONS exchange negotiates, and why credentialed CORS forbids the wildcard origin.

Cross-origin resource sharing is not a permission system for servers. It is a protocol by which a browser decides whether to hand a response body to a script that asked for it from a different origin. The server’s headers describe which origins may read the response; the browser enforces the description. A curl invocation ignores every CORS header, which is why CORS never protects an endpoint that lacks authentication or authorization of its own.

The design problem is a legacy one. Before fetch existed, pages could embed cross-origin images, scripts, stylesheets, and forms, and those mechanisms sent credentials automatically. Adding a general cross-origin read API without a gate would have let any page read any other page’s authenticated responses. The gate chosen was server opt-in, with a carve-out for the request shapes that were already possible before the API existed, so that existing sites would not break.

The simple-request carve-out

A cross-origin request is sent without a preflight when it satisfies all of the following:

  • The method is GET, HEAD, or POST.
  • Every author-specified header is a CORS-safelisted request header: Accept, Accept-Language, Content-Language, Range (with restrictions on the value), and Content-Type limited to application/x-www-form-urlencoded, multipart/form-data, or text/plain.
  • No event listener is registered on the upload object, and no ReadableStream is used as the body.

The list is historical. Content-Type: application/json is not safelisted even though it is the most common API content type, because a page could not send it cross-origin before fetch existed. Authorization is not safelisted. Any custom header, such as X-Requested-With or a trace identifier, forces a preflight. The safelist is why so many internal APIs accept JSON only after an OPTIONS round trip, and why a “no-cors” submission is restricted to forms-compatible encodings.

A response to a simple request is still gated on read. The browser sends the request, receives headers and body, and then checks Access-Control-Allow-Origin. If it fails, the script sees a network error with no status, no headers, and no body. Only the safelisted response headers — Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma — are visible even on success; everything else needs Access-Control-Expose-Headers.

What the preflight negotiates

A preflight is an OPTIONS request to the same URL, carrying Origin, Access-Control-Request-Method, and, when custom headers are present, Access-Control-Request-Headers as a comma-separated lowercase list. It carries no request body and never carries credentials, whatever the credentials mode of the original request.

OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, x-trace-id

The server answers with a set of allowances. Access-Control-Allow-Origin must be the requesting origin or *; Access-Control-Allow-Methods must contain the requested method; Access-Control-Allow-Headers must contain each requested header, matched case-insensitively. The browser then compares the request it is about to make against those values. If everything matches, the real request is issued. If not, the real request is never sent, and the script receives a network error.

Two details follow from this ordering. First, a failed preflight means the side effect never happens, so CORS does something real for non-simple requests: it prevents the request, not just the read. Second, the preflight is a separate HTTP request, so a cross-origin PUT costs two round trips on a cold cache. Access-Control-Max-Age sets how long the result may be cached; absent the header, the cache lifetime is five seconds, and browsers impose their own ceiling regardless of what the server sends. The cache key includes the origin, the URL, the method, and the header list, so varying any of them triggers a fresh exchange.

Preflights are not sent for same-origin requests, and they are not sent when the request’s mode is no-cors. A no-cors request produces an opaque response whose status is always 0 and whose body is unreadable; it exists for the legacy embedding cases and for service worker pass-through, not for application data.

Credentials break the wildcard

For a cross-origin request, fetch sends no cookies, TLS client certificates, or HTTP authentication unless the caller sets credentials: 'include'. With that mode, three rules apply:

  • Access-Control-Allow-Origin must be the exact origin. * is rejected, and so is a response that omits the header.
  • Access-Control-Allow-Credentials: true must be present on both the preflight and the actual response.
  • Access-Control-Expose-Headers must name any response header the script needs. Set-Cookie is a forbidden response-header name and cannot be exposed under any configuration.

The wildcard prohibition is the important one. If * were honoured with credentials, any origin that could get a request to the endpoint would read an authenticated response, and the header would convey nothing. Requiring the server to echo a specific origin forces an explicit decision per caller, and makes the value of the header depend on a server-side check rather than on a default.

The echo itself is where implementations go wrong. Reflecting the Origin header back — Access-Control-Allow-Origin: <whatever arrived> — combined with Allow-Credentials: true is equivalent to allowing every origin, and it is a common pattern because it makes local development and multiple front-end hosts work without configuration. When the endpoint uses cookie authentication, this is a full cross-origin read primitive for any site the user visits.

Redirects, null, and other edge behaviour

A preflight cannot follow a redirect; the fetch fails. A cross-origin redirect of the actual request drops the Authorization header, and the final response must still name the origin that initiated the fetch in Access-Control-Allow-Origin. The practical consequence is that CORS endpoints should be addressed at their final URL.

Origin: null arrives from sandboxed frames without allow-same-origin, from data: URLs, from some file: URLs, and from certain redirect chains. An allowlist that includes null is an allowlist that includes every sandboxed document on the web, including ones an attacker can create trivially. It should never be added, even for local testing, without an understanding that it is equivalent to * for credentials purposes.

Private Network Access adds a further preflight for requests from a public page to a private address space, negotiated with Access-Control-Request-Private-Network and Access-Control-Allow-Private-Network. Its purpose is to make the DNS rebinding case from origin computation and the same-origin policy require an explicit server opt-in.

Errors are deliberately opaque

Every CORS failure surfaces to script as a generic TypeError with no message, no status, and no response. The console shows a diagnostic, but the page cannot read it, cannot distinguish “server rejected the origin” from “network down”, and cannot inspect which header was wrong. This is a deliberate anti-probing measure: if a page could tell whether a server’s allowlist had rejected it, it could enumerate which origins an intranet service trusts, or detect the presence of an internal host. The opacity costs developers time and is not negotiable.

Vary: Origin belongs on every response whose Access-Control-Allow-Origin depends on the request’s Origin, including the preflight response. Without it, a shared cache can store the response generated for one origin and serve it to another, which either breaks legitimate callers or hands an attacker an allow header for an origin the server never authorised. The same applies to Vary: Access-Control-Request-Headers on preflight responses that echo the requested header list.

Where the allowlist check goes wrong

Allowlist validation is string comparison, and string comparison done by hand fails in predictable ways. A check such as origin.endsWith('example.com') accepts https://evil-example.com and https://example.com.attacker.net. A regular expression built by interpolating the allowed host without escaping dots accepts https://exampleXcom. A check that parses the origin with a URL parser and compares only the host ignores the scheme, which admits an http origin that a network attacker can serve. A check that compares against req.headers.origin when the header is absent — a non-browser client, or a same-origin GET — sees undefined and must not fall through to allow.

The correct form parses the origin, requires an exact match against a set of full origin strings including scheme and port, and rejects null explicitly. Because the browser does not send Origin for same-origin GET and HEAD requests, a rule of “allow if absent” is common and usually correct only when the endpoint is already protected by something else. CORS decides who may read a response, not who may cause it, which is why an endpoint with a carefully restricted allowlist still needs the request-origin checks described in CSRF defence: synchronizer tokens, fetch metadata, and SameSite.

References

  1. Fetch Standard — CORS protocol — WHATWG
  2. RFC 9110: HTTP Semantics — IETF