skip to content

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

level: middleimportance: must knowfreq 52%

answer

  1. two legs, two different shapes
  2. the first leg is a separate fetch
  3. its credentials mode is fixed
  4. "same-origin" attaches nothing cross-origin
  5. names travel early, values travel late

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.

solid answer

~40 s

The preflight is not the real request with a different method; it is its own fetch, built by the browser, and its credentials mode is fixed at `"same-origin"`. Since it goes to another origin, that mode attaches nothing - no cookie, and nothing else the browser would normally add on its own. The real call's credentials mode does not propagate to it. It also carries no body and no header **values**: a header the calling code sets, such as `Authorization`, appears on that leg only as a lower-cased **name** inside `Access-Control-Request-Headers`. So anything on the server that runs before routing sees an anonymous, empty `OPTIONS` and has to answer from the method and the names alone.

code

http · 14 lines
http
OPTIONS /animals/movements HTTP/1.1
Host: api.herd-registry.example
Origin: https://tracing.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
Accept: */*

POST /animals/movements HTTP/1.1
Host: api.herd-registry.example
Origin: https://tracing.example
Authorization: Bearer 8f2c...
Cookie: herd_session=6a1d...
Content-Type: application/json
Content-Length: 58

go deeper

for a junior

Remember that the first of the two requests is built by the browser and arrives with no cookie and no body. If you see an empty OPTIONS in a server log next to a cross-origin call, that is expected, not broken.

for a middle

Explain it through the mechanism: the preflight is its own fetch with credentials mode fixed at "same-origin", and the target is a different origin, so nothing ambient is attached regardless of what the real call intends.

for a senior

Show the operational consequence: any filter on the preflight path runs against an unauthenticated request by design, so identity-shaped decisions have to sit on the second leg, and a rate limiter or tenant resolver keyed on identity will mis-handle that first leg.

for a principal

The tradeoff to own is where the anonymous leg is handled across many services, so that one shared, identity-free branch answers it consistently rather than each service discovering the emptiness for itself.

## The symptom A traceability page on `https://tracing.example` calls a herd-registry API on another host, and the call is meant to carry the operator's session. The API's logs show an `OPTIONS` arriving first, with no `Cookie` field, no `Authorization` value, and no body - and then, if the exchange completes, the real request arriving fully dressed. Teams read the empty first request as a bug in the calling code. It is not; it is the specified shape of a **CORS-preflight request**. ## Why it arrives anonymous Every fetch has a **credentials mode**, and it is one of three values: `"omit"`, `"same-origin"` or `"include"`. The mode decides whether the browser attaches the things it would otherwise add by itself when talking to that host. The preflight is a distinct fetch that the browser constructs. Its credentials mode is **fixed** at `"same-origin"` - it is not inherited from, and not influenced by, the mode of the request that provoked it. And `"same-origin"` means exactly what it says: attach ambient credentials only when the target is the page's own origin. A preflight by definition goes to another origin, so the mode attaches nothing. That single fixed value is the whole mechanism. It is worth stating as the mechanism rather than as "preflights never have cookies", because the reason matters: the mode is what is fixed, and the empty `Cookie` field is the consequence. ## What is on that leg, and what is not | On the preflight | On the real request | |---|---| | `Origin` | `Origin` | | `Access-Control-Request-Method`, and the names in `Access-Control-Request-Headers` | the actual header fields, with their values | | `Accept: */*` | the real `Accept`, `Content-Type` and the rest | | no body | the body, if the method has one | | nothing ambient, because the credentials mode is `"same-origin"` | whatever the real call's credentials mode allows | Two consequences follow directly: - **A value never travels early.** If the calling code sets `Authorization`, the preflight discloses the lower-cased name `authorization` and nothing else. The credential itself is on the second leg only. - **Server-side code on the preflight path is blind by design.** Anything that runs before the handler - an authentication filter, a tenant resolver, a rate limiter keyed on identity - sees an unauthenticated caller on that leg. The request it is looking at carries no identity because none was ever sent, not because the browser dropped one. ## What that means for how the preflight is answered The browser is asking a question about **shape**, not about a subject: may a request with this method, carrying these header names, be made to this URL from this origin? That question is answerable without knowing who is calling. Treating the preflight as something to authorise - deciding its answer from a session that is not there - is asking a question the specification guarantees you cannot answer on that leg. The acceptance rule on the browser's side is equally shape-shaped. The answer must come back with an **ok status**, any status in the 200-299 range, or the whole fetch ends as a network error and the real request is never sent at all. `200` and `204` are the specification's examples. So the preflight path has to be able to produce a successful, anonymous answer, and every richer decision - is this operator allowed to record this movement? - belongs on the second leg, where the credential actually arrives. ## The reversal to keep straight This material stays grammatical when stated backwards, so check the direction explicitly: 1. The **browser** sends `Origin`, `Access-Control-Request-Method` and `Access-Control-Request-Headers`. They are request headers and they exist only because the browser put them there. 2. The **server** answers. It grants; it does not block. A server that fails to grant has not blocked a read - the browser withholds it. 3. The preflight's emptiness is **not** a signal about the real request. The real call may well be credentialed; what the two legs carry differs by specification, and a server that infers "this client sends no cookies" from the first leg has read the wrong request.

  • The calling code opts the request into credentials mode "include". Does that change what the preflight carries?
    No. The preflight's own credentials mode is fixed at `"same-origin"` and is not inherited from the request that provoked it, so it still arrives with nothing ambient attached. The chosen mode governs the second leg only, and it is also recorded against the preflight cache entry so that a credentialed and an uncredentialed call do not share one.
  • What can a server legitimately decide from a preflight, given that it arrives anonymous?
    Only shape: whether a request using that method, carrying those header names, is acceptable to that URL from that origin. All three inputs - `Origin`, `Access-Control-Request-Method` and `Access-Control-Request-Headers` - are on the request, and none of them identifies a caller. Decisions about a subject belong on the real request, where the credential arrives.
  • If the real call will send an Authorization header, what does the server see of it during the preflight?
    Only the lower-cased name `authorization` inside `Access-Control-Request-Headers`. The value is never on that leg. This is why the preflight is a question about permitted header names rather than a chance to verify a token early.

saying these in an interview costs you the question

  • Thinks the preflight carries the session cookie the real call will send
  • Says the preflight repeats the real request's Authorization value
  • Believes the preflight's credentials mode follows the real request's mode
  • Treats an anonymous preflight as a client-side misconfiguration
  • Tries to authorise a subject on the preflight leg