skip to content

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

level: seniorimportance: must knowfreq 60%

answer

  1. two exchanges, two independent checks
  2. the preflight is sent anonymously
  3. checked against the request it clears
  4. credentials mode "same-origin" on the OPTIONS
  5. no follow-up request means leg one failed

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.

solid answer

~40 s

A CORS-preflight request is always made with credentials mode `"same-origin"`, so it carries no cookie, no client certificate and no authentication entry, and no body either. But the check run on its response is not about the preflight — it is run against the **original** request, the one whose mode is `"include"`. That check demands `Access-Control-Allow-Credentials: true` and refuses `Access-Control-Allow-Origin: *`. So the server has to advertise credential support while looking at an anonymous asking request. If it does not, the preflight fails and the browser never sends the real request at all. The actual response is then checked again on its own headers, so the grant is owed on **both** legs — two separate checks, not one remembered decision.

code

http · 13 lines
http
OPTIONS /maintenance-requests HTTP/1.1
Host: api.portal.example
Origin: https://portal.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://portal.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

go deeper

for a junior

Remember that a preflight is a separate OPTIONS exchange the browser sends first, and that it travels without any cookie attached.

for a middle

Explain that both responses need the grant independently, and that a failed preflight means the real request was never sent at all.

for a senior

Demonstrate the diagnosis: an OPTIONS in the log with no follow-up request points at the preflight branch, which usually writes headers through a different code path than the handlers.

for a principal

The design question is why the protocol demands a blind commitment before any credential is risked, and what that implies for how a fleet's preflight branch is owned and tested.

## Two legs, two checks A credentialed cross-origin call that needs a preflight is really two HTTP exchanges: 1. The **preflight**: an `OPTIONS` request the browser generates, carrying `Origin`, `Access-Control-Request-Method` and, if needed, `Access-Control-Request-Headers`. It has no body. 2. The **actual request**, sent only if the preflight succeeded, carrying the credentials. Each leg's response is subjected to its own **CORS check**. Nothing about passing the first check is remembered as having satisfied the second. The grant fields must therefore appear on both responses — and the one that surprises people is the first. ## The preflight is sent blind The preflight is made with credentials mode `"same-origin"`. The request is cross-origin, so that mode attaches nothing: no cookie, no TLS client certificate, no authentication entry. From the server's point of view an anonymous `OPTIONS` arrives, announcing only an origin, a method and a list of header names. So why is it asked for a credential grant? Because the check performed on the preflight response is run **against the original request** — the one the browser is holding back — and that request's credentials mode is `"include"`. The two consequences follow immediately: - `Access-Control-Allow-Credentials: true` is required on the preflight response. - `Access-Control-Allow-Origin: *` is refused on the preflight response; the exact serialized origin the browser sent must be echoed instead. The server is being asked to commit, in advance and in the blind, to something it has no evidence it will need. That is the whole design: the browser will not risk sending the tenant's cookie to another origin until the holder of that origin has said, on the record, that it accepts credentialed reads. ## What goes wrong in practice A tenant portal calls a maintenance-request API on a second host. The team configures the API's response-header block correctly, tests the personalised list, and everything works — for the reads that need no preflight. Then a form starts sending a content type that pushes the call over the preflight line, and every write breaks at once. The diagnosis is characteristic: 1. The API's own logs show an `OPTIONS` answered `204` and then **nothing**. The actual request never arrives. 2. Script reports a network error with no status and no body. 3. The team inspects the actual response's headers and finds them perfect — because the actual response was never produced. The defect lives on a leg nobody instrumented, answered by a branch of the code that often does not run through the same header-writing path as the real handlers. | | Preflight leg | Actual leg | |---|---|---| | Method | `OPTIONS`, browser-generated | The method the code chose | | Credentials carried | None; mode is `"same-origin"` | The three ambient credentials, mode `"include"` | | Body | None | Whatever the call sends | | Grant needed | `Allow-Credentials: true`, exact origin | `Allow-Credentials: true`, exact origin | | Failure means | Actual request never sent | Response received but withheld from script | ## Order of consequences The direction matters, and it is easy to state backwards: 1. The preflight fails → the actual request is **never sent** → the server never saw it, nothing was created, nothing was read. 2. The preflight succeeds but the actual response lacks the grant → the server **did** handle the request, side effects happened, and only the reading of the answer is refused. Those are different incidents with different blast radii, and "the CORS call failed" describes both without distinguishing them. The server's own log settles which one you have: an `OPTIONS` with no follow-up is case one. ## The rule, stated carefully - The preflight carries no credential, and it is checked as though it did, because the check is about the request it is clearing. - Both responses owe the grant; neither inherits it from the other. - The browser's CORS-preflight cache can spare you the `OPTIONS` next time, but it caches the preflight's outcome — it never supplies the actual response's grant. - The server grants; the browser enforces. A missing grant is not a server-side denial, and the request may well have taken effect.

  • The preflight was answered correctly but the actual response omits the grant. What is the difference in impact?
    The actual request was sent and handled, so any side effect it had is real — a maintenance request was created, a record was updated. Only the reading of the answer is refused, and script sees a network error. When the *preflight* fails instead, the actual request is never sent, so nothing happened server-side at all. The server's log distinguishes them in one glance.
  • Does the CORS-preflight cache let the actual response skip the grant?
    No. `Access-Control-Max-Age` caches the preflight's outcome so the `OPTIONS` need not be repeated for a matching method and header set. It caches permission to skip the asking leg, not the grant that the actual response must carry. Every actual response is checked on its own headers, cache or no cache.
  • Why can the preflight response not use `Access-Control-Allow-Origin: *` here?
    Because that response is checked against the original request, whose credentials mode is `"include"`, and the wildcard is not accepted under credentials on either leg. The server must echo the exact serialized origin the browser sent in `Origin` — which means the preflight branch needs the same origin-selection logic as the handler path, not a fixed constant.

The preflight is a scout sent ahead with empty pockets. The venue has to state, while looking at someone carrying no pass at all, that people carrying passes are admitted — otherwise the pass-holder is never sent.

saying these in an interview costs you the question

  • The preflight carries the cookie, so the server can authorise it
  • Granting credentials once on the preflight covers the actual response
  • A failed preflight still lets the real request through, unread
  • The preflight cache stores the grant for later responses
  • The server blocked the call, since the browser reported a CORS failure
  • Only the actual response needs Allow-Credentials, since only it is credentialed