Reading a Preflight OPTIONS Request in DevTools

You opened the Network tab to debug a cross-origin call and hit one of two walls: there is no OPTIONS request anywhere in the list, or there is one and it is highlighted red with a status that looks fine. This page shows how to surface the preflight row and read it so the failing header is obvious.

This page is part of Inspecting Preflight in the DevTools Network Panel, which covers the panel end to end.

The Symptom

Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: Request header field
authorization is not allowed by Access-Control-Allow-Headers in preflight response.

In the Network tab this pairs with either a missing OPTIONS row (you scrolled past it, or the log cleared) or a red OPTIONS row whose status line reads 204 — which looks successful but is not, because the response is missing a header the browser required.

Root Cause

The WHATWG Fetch Standard (§4.8) requires the preflight response to explicitly list every non-simple request header in Access-Control-Allow-Headers and the intended method in Access-Control-Allow-Methods. The browser performs an exact, case-insensitive token comparison between what it declared in Access-Control-Request-Method/Access-Control-Request-Headers and what the server allowed. If a single declared token is absent from the corresponding allow list, the CORS-preflight fetch returns a failure and the actual request is never dispatched — regardless of the 204/200 status. The status code describes the HTTP transaction; the headers decide the CORS outcome.

Two independent verdicts are being computed on the same response, and the Network panel only shows you one of them in the Status column:

Two verdicts computed on one preflight response A single preflight response feeds two parallel checks. The HTTP transaction check is satisfied by the 204 status and empty body, while the CORS token comparison fails because a declared request header is absent from the allow list. Both feed one outcome: the actual request is never sent. The preflight response arrives HTTP/2 204 · empty body · Access-Control-Allow-Headers: content-type Check 1 — the HTTP transaction a 2xx status is a valid preflight reply an empty body is expected here satisfied — this is the Status column Check 2 — the token comparison every declared request token must appear in the matching allow list failed — authorization is absent Outcome: the CORS-preflight fetch fails and the real POST is never dispatched the row still reads 204, because that number reports check 1 and says nothing about check 2 Read the headers, not the status — only one of the two verdicts is shown in the list view

Prerequisite State

Step-by-Step Fix

Step 1 — Enable Preserve Log

The failed request commonly rides on a navigation that wipes the Network list. Turn on Preserve log (Chrome) or Persist Logs (Firefox, via the gear icon) so the OPTIONS row is retained.

Step 2 — Filter to the Preflight

In Chrome, type a method filter into the filter box:

method:OPTIONS

Firefox lacks a method token, so type the endpoint path fragment (for example orders) into the filter box and read the Method column to find the OPTIONS entry.

Knowing what the row looks like among its neighbours is half the battle — it is one line among the document, asset, and telemetry traffic:

Finding the preflight row in the Network list A mock Network panel with six rows across Name, Method, Status and Type columns. The OPTIONS row for the orders endpoint is highlighted among ordinary asset and fetch rows, and two notes beside the table explain what Preserve log and the method filter contribute. Network panel, no filter applied Name Method Status Type main.css GET 200 stylesheet app.js GET 200 script orders OPTIONS 204 preflight session GET 200 fetch metrics POST 204 fetch sprite.svg GET 200 image Preserve log ON keeps the preflight row alive across the navigation that triggered the failing call method:OPTIONS hides the five asset rows and leaves this one row Firefox: filter by path instead The Type column reads preflight, not xhr — that is the fastest way to spot the row by eye

Step 3 — Open the OPTIONS Row’s Headers Pane

Click the OPTIONS entry and select the Headers pane (not Response — a preflight body is empty by design). You will read two sections: Request Headers and Response Headers.

Step 4 — Read the Request Declaration

Under Request Headers, note the browser’s declaration:

Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

This says: “I am about to send a POST carrying authorization and content-type headers.”

Step 5 — Read the Server’s Permission and Find the Gap

Under Response Headers, read what the server allowed:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: content-type

Compare token by token. POST is present in Access-Control-Allow-Methods — good. But authorization from the request is absent from Access-Control-Allow-Headers, which lists only content-type. That single missing token is the failure. The fix is server-side: add authorization to the allow list, as covered in Access-Control header directives.

Two comparison details catch people out. The browser lowercases and sorts the tokens it puts in Access-Control-Request-Headers, so a client that set Authorization will show authorization in the panel — that is normalisation, not a different header. And Access-Control-Allow-Headers: * does not cover Authorization: the Fetch Standard excludes it from wildcard expansion, so a credentialed request still needs it named explicitly.

The diagram below shows this side-by-side comparison.

Comparing request declaration against server permission Left column lists the browser's Access-Control-Request-Method POST and Access-Control-Request-Headers authorization. Right column lists the server's Access-Control-Allow-Methods GET POST and Access-Control-Allow-Headers content-type. The method row matches in green; the header row mismatches in red because authorization is absent. Browser asked Server allowed Access-Control-Request-Method POST Access-Control-Allow-Methods GET, POST match Access-Control-Request-Headers authorization Access-Control-Allow-Headers content-type (no authorization) gap One missing token fails the whole preflight

Step 6 — When the OPTIONS Row Is Genuinely Absent

If the filter is applied, Preserve log is on, and there is still no OPTIONS entry, the preflight was never sent. Work down these causes in order:

  1. No preflight was required. A GET or POST carrying only safelisted headers is a simple request. The CORS failure then belongs to the actual request row — open that row instead and look for a missing Access-Control-Allow-Origin on the response.
  2. The preflight was served from the CORS-preflight cache. Within the Access-Control-Max-Age window the browser reuses the stored result and renders no row for it. The Disable cache checkbox does not clear this cache; restart the browser, or change the method or header set so the cache key no longer matches.
  3. The list is filtered to Fetch/XHR. Chromium classifies preflights under their own type, so switch the type filter to All before concluding the row is missing.
  4. A service worker handled the request. The preflight then originates from the worker’s fetch, and appears under the service worker’s network context rather than the page’s.
  5. The browser refused to send it. A blocked port, mixed content, or an extension rule produces net::ERR_* in the console instead of a CORS message, and no row is created at all.

Only cause 1 and cause 2 are normal. The other three mean the request never reached your server, so no amount of header configuration will change the result.

Verification

Confirm the server now allows the header from the command line, outside the browser:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization, content-type" \
  | grep -i "access-control-allow-headers"

The output must list authorization:

access-control-allow-headers: authorization, content-type

Then re-run the request in the browser: the OPTIONS row should no longer be red, and the actual POST row should now appear beneath it. The full command-line technique is in Simulating a Preflight with curl -X OPTIONS.

Security Boundary Note

Do not “fix” a missing-header preflight by reflecting the request’s headers back wholesale — for example echoing Access-Control-Request-Headers straight into Access-Control-Allow-Headers without an allow list. Blind reflection means any header a caller names is automatically permitted, which erodes the server’s control over what it accepts. Maintain an explicit allow list and add only the specific headers your API legitimately consumes.

Common Mistakes

Mistake Technical impact Fix
Reading the OPTIONS status 204 as success The preflight fails on a missing header despite a 2xx status Compare Access-Control-Request-* against Access-Control-Allow-*, not the status code
Looking in the Response tab for the answer A preflight body is empty; the tab shows nothing useful Read the Headers pane’s Response Headers section
Filtering the whole list and missing the row The OPTIONS entry is buried among document and asset requests Use method:OPTIONS (Chrome) or filter by the endpoint path
Assuming a missing row means the server dropped the preflight A cached preflight renders no row at all, and Disable cache does not clear the preflight cache Restart the browser or change the method/header set, then re-check

FAQ

The OPTIONS row is red. Does that mean the server is broken?

Not necessarily. A red OPTIONS row means the browser judged the preflight response insufficient for the actual request it wanted to make. The server may have returned a valid 200 or 204 that simply omits a required Access-Control header. Open the row and compare the request’s Access-Control-Request values against the response’s Access-Control-Allow values to see which one is missing.

Why does the Response tab of the OPTIONS request show nothing?

A preflight legitimately has an empty body, so an empty Response tab is normal for OPTIONS. The information you need is in the Headers pane, not the Response tab. Read the Response Headers section for the Access-Control-Allow directives.

Why is there no OPTIONS row even though the request is cross-origin?

Either no preflight was required or the browser reused a cached one. A request that uses only safelisted headers and a simple method never preflights, so the CORS failure belongs to the actual request row. If the request does preflight, a stored result inside the Access-Control-Max-Age window is reused with no row rendered, and the Disable cache checkbox does not clear that cache — restart the browser or change the method or header set to force a fresh OPTIONS.

Does Access-Control-Allow-Headers: * cover the Authorization header?

No. The Fetch Standard excludes Authorization from wildcard expansion, so a request declaring Access-Control-Request-Headers: authorization fails against an allow list of * exactly as it would against an allow list that omits the name. You must list Authorization explicitly. The wildcard is also ignored entirely when the request is credentialed, so an API used with cookies needs every header enumerated by name regardless.