skip to content

Simple vs Preflight Requests

What a browser actually puts in an OPTIONS preflight before a cross-origin call, and how long Access-Control-Max-Age lets it skip asking again. 'Why two requests?' is the standard first bug.

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

questions

4

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%

answer

  1. one call, two round trips
  2. the browser asks before it sends
  3. an OPTIONS request with no body
  4. Access-Control-Request-Method names the real method
  5. unsafe names, lower-case, sorted, comma-joined

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.

solid answer

~40 s

The browser builds that first request itself; the calling script never sees it. It uses the method `OPTIONS` against the same URL, stamps the page's `Origin`, and adds two request-side fields of its own: `Access-Control-Request-Method`, carrying the single method the real request will use, and - only when the real request will carry CORS-unsafe request-header names - `Access-Control-Request-Headers`, carrying those names as a sorted, lower-cased set joined by `,` with no space after the comma. Its own `Accept` is `*/*`. It carries **no body**, and its credentials mode is `"same-origin"`, so nothing ambient travels with it. The browser sends the real request only if the answer comes back with an ok status and grants what was asked about.

code

http · 6 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: content-type,x-herd-consignment
Accept: */*

go deeper

for a junior

Recall the shape: one cross-origin call can become two requests, the first being an OPTIONS the browser built by itself and never showed your code. Know the two field names it adds and that it carries no body.

for a middle

Explain the construction field by field, and above all say which side sends which: the Request pair goes out from the browser, the Allow fields come back from the server. Name the sorted lower-case comma-joined format of the header-name list.

for a senior

Show that you have read this off a real capture: the preflight arrives anonymous and empty, so server-side handling of it must decide from the method and the names alone, and the answer's status has to land in the ok range before the real request ever exists.

for a principal

The angle worth owning is where the preflight branch lives across an estate - a shape fixed by the specification is a shared concern that belongs in one place, not re-derived per service, and its answer is the only thing teams tune.

## One call, two round trips A page served from `https://tracing.example` asks a livestock traceability API on `https://api.herd-registry.example` for an animal's movement history, and adds one identifier field of its own to the call. The calling code issues **one** request. A packet capture at the API shows **two**. The first is a **CORS-preflight request**: a request the browser constructs on its own initiative, sends to the same URL as the real call, and never hands back to the script. Only when its answer is acceptable does the browser send the request the script actually asked for. Which cross-origin calls get a preflight at all is a separate rule, decided from the method and the header names the call would carry. This explanation starts one step later - a preflight is happening - and describes the exchange itself. ## What the browser puts in it - **The method is `OPTIONS`.** The preflight is a request in its own right, not the real request with a different verb attached. It targets the same URL. - **`Origin`** carries the calling page's origin, the same value the real request will carry. It is a request header the browser stamps; the script cannot set it. - **`Access-Control-Request-Method`** carries exactly one method name: the method the real request is going to use. Not a list, not the preflight's own method. - **`Access-Control-Request-Headers`** appears **only** when the real request will carry CORS-unsafe request-header names. Its value is that set of names, byte-lowercased, deduplicated, sorted, and joined by `,` with **no space** after the comma - so a server comparing the value against a configured list must parse it case-insensitively and must not expect `, ` separators. - **`Accept: */*`.** The preflight sets its own `Accept`; it does not copy the real request's. - **Request mode `"cors"`, credentials mode `"same-origin"`.** Because the target is another origin, the fixed credentials mode means nothing ambient is attached, whatever the real call intends. - **No body at all.** There is nothing for the server to parse, and no `Content-Length` to describe a payload. ## Which side sends which field This is the reversal that costs candidates the question: the two `Request` fields go out from the browser, and every `Allow` field comes back from the server. | Field | Direction | Appears on | |---|---|---| | `Origin` | browser to server | the preflight and the real request | | `Access-Control-Request-Method` | browser to server | the preflight only | | `Access-Control-Request-Headers` | browser to server | the preflight only, and only when unsafe names exist | | `Access-Control-Allow-Methods` | server to browser | the answer to the preflight | | `Access-Control-Allow-Headers` | server to browser | the answer to the preflight | | `Access-Control-Max-Age` | server to browser | the answer to the preflight | A stem or a log line that says only "the Access-Control headers" has named both sets and identified neither. ## What the preflight is not 1. **It is not the real request.** It replays neither the body nor the header field values; it only *names* the method and the unsafe header names. An `Authorization` value the real call will carry appears in `Access-Control-Request-Headers` as the name `authorization`, and the value itself is nowhere on that leg. 2. **It is not an authenticated request.** Its credentials mode is fixed, so the server handling it sees an anonymous caller and must decide from the method and the names alone. 3. **It is not a question about the response.** The preflight asks whether this method and these header names may be used. What the eventual response may contain is governed separately, by the fields the server answers with. ## What has to come back for the exchange to continue The bare acceptance rule is about the **status**: the answer must be 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. `200` and `204` are the specification's own examples, and `204` is the common choice because there is nothing to return. A server that answers a preflight with a status outside that range has ended the call before the real request existed. The status is necessary and not sufficient: the answer must also grant the method and the header names that were asked about. Those grant fields, and the failure modes when they are missing, belong to the answering side of the exchange - the point here is that the browser asked, in one narrow, fixed shape, and asked before it sent anything that could change state.

  • What status must the answer to a CORS-preflight request carry for the real request to be sent at all?
    An ok status - anything in the 200-299 range; `200` and `204` are the specification's own examples. A status outside that range ends the whole fetch as a network error and the real request is never sent. The status alone is not enough: the answer still has to grant the method and the header names that were asked about.
  • In what order and format does a browser write the names in Access-Control-Request-Headers?
    As a sorted set of byte-lowercased field names joined by `,` with no space after the comma, for example `content-type,x-herd-consignment`. Duplicates are removed. A server that string-compares the value against a configured list must lower-case its own list and must not assume `, ` as the separator.
  • Does the preflight ask anything about the request body or about the response the real call will return?
    Neither. It carries no body, and it asks exactly two things: which method the real request will use, and which CORS-unsafe request-header names it will carry. What the eventual response may contain, and which of its headers script may read, are decided separately by the fields the server sends on the real response.

It is a call ahead to the loading bay: the driver asks whether a delivery of that kind, with those labels on it, will be accepted. He brings no parcel and shows no pass - just the description - and only drives over once the answer is yes.

saying these in an interview costs you the question

  • Says the server sends Access-Control-Request-Method to the browser
  • Thinks the preflight replays the real request's body for validation
  • Believes the preflight is the real request with the method swapped
  • Says Access-Control-Request-Headers lists every header the real call sends
  • Thinks the page's script adds the Access-Control-Request fields itself
  • Assumes the preflight carries the real request's Authorization value
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

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 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