Why Preflight Requests Use the OPTIONS Method

When a cross-origin fetch() call fails with a message about a preflight check, the browser has already sent an OPTIONS request that your server did not handle correctly. This page explains why OPTIONS is the mandated method for that probe, what the server must return, and how to verify the fix.

This page is part of Simple vs Preflight Requests: CORS Mechanics, which covers the full classification logic that determines whether a browser sends a preflight at all.

The Exact Error This Page Resolves

Access to fetch at 'https://api.service.internal/data' from origin
'https://app.client.internal' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

This error appears in the browser console when the OPTIONS preflight response is missing one or more required CORS headers — or when the server returns a non-2xx status for OPTIONS entirely.

Root Cause

The WHATWG Fetch Standard (section 4.8) requires browsers to send a preflight OPTIONS request before any cross-origin request that is not classified as “simple.” The browser chose OPTIONS specifically because RFC 9110 (which obsoletes RFC 7231) designates it as safe (no server state modification) and idempotent (repeated calls produce the same result). Using GET or POST for the probe would risk triggering business logic or creating resources before any permission check has been granted.

The server must respond with explicit Access-Control-Allow-* headers on the OPTIONS response. If it does not — or if a WAF, reverse proxy, or missing route handler drops the request — the browser blocks the actual request entirely and surfaces the error above.

RFC 9110 draws two lines that leave OPTIONS as the only workable candidate. Section 9.2.1 defines a safe method as one whose semantics are read-only, and section 9.2.2 defines an idempotent method as one where several identical requests have the same effect on the server as a single one. OPTIONS satisfies both, and section 9.3.7 additionally defines it as a request for information about the communication options available for a resource — which is exactly the question a preflight asks. GET and HEAD are also safe and idempotent, but they are already the methods a simple request uses, so neither the server nor any cache, access log, rate limiter, or analytics pipeline in the path could distinguish a permission probe from a genuine read; every cross-origin call would be counted twice. POST, PUT, and PATCH are neither safe nor idempotent, so a probe using them could create or mutate a record before permission had been granted. Inventing a new verb would be worse still: a non-standard method is itself never simple, so the probe would need its own preflight, and intermediaries that answer unrecognised verbs with 501 Not Implemented would break the handshake before it started.

One consequence of that design catches teams out repeatedly. The preflight is composed by the browser, not by your code, and the Fetch Standard specifies that it is sent with the credentials mode set to omit: no cookies, no Authorization header, no client certificate. A server that routes OPTIONS through its authentication filter will therefore reject its own preflight with 401 or 403, and the actual request is never attempted. The OPTIONS handler has to sit in front of authentication, not behind it.

Preflight OPTIONS handshake sequence Sequence diagram showing browser sending OPTIONS preflight to server, server responding with Access-Control headers, then browser sending the actual POST request and receiving the response. Browser Server OPTIONS /data (preflight) Origin: https://app · Access-Control-Request-Method: POST 204 No Content Access-Control-Allow-Origin · Access-Control-Allow-Methods · Vary: Origin POST /data (actual request) Origin: https://app · Content-Type: application/json 200 OK (response body delivered)

What the Browser Sends in the Probe

The preflight is not a generic capability query. It is a narrow, three-part question about a request that has not happened yet, and each part expects one specific header in reply. The browser sets Origin to the serialized origin of the requesting page, Access-Control-Request-Method to the method the real request will use, and — only when the real request will carry headers outside the CORS-safelist — Access-Control-Request-Headers to a comma-separated list of those header names, lowercased and byte-sorted. There is never a request body, so Content-Length: 0 on the probe is normal rather than a sign of truncation.

Lining the two header sets up side by side makes the pairing obvious:

Each preflight request header maps to one response header The left panel lists the three headers the browser puts on the OPTIONS probe: Origin, Access-Control-Request-Method and Access-Control-Request-Headers. Arrows connect each to the response header on the right that must answer it: Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers. The preflight asks three questions about a request that does not exist yet OPTIONS request headers Response headers that answer Origin: https://app.client.internal Access-Control-Request-Method: POST Access-Control-Request-Headers: x-custom-header Access-Control-Allow-Origin: https://app.client.internal Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: Content-Type, x-custom-header Every question must be answered on the OPTIONS response itself, not on the actual request

Two details in that pairing trip up hand-written handlers. First, the browser matches Access-Control-Request-Headers against Access-Control-Allow-Headers case-insensitively but name for name, so a handler that echoes back only the header names it happens to recognise will silently omit something like x-request-id and fail the check. Second, Access-Control-Allow-Methods is evaluated as a complete list rather than a single answer: returning only the verb that was asked about is legal, but it forces a fresh preflight for every distinct method, whereas listing the endpoint’s full verb set once lets a single cached result cover them all.

Prerequisite State

Before applying the fix below, confirm:

Step-by-Step Fix

1. Add an explicit OPTIONS handler (Express.js)

app.options('/data', (req, res) => {
  const allowedOrigins = ['https://app.client.internal'];
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Custom-Header');
  res.setHeader('Access-Control-Max-Age', '600');
  res.sendStatus(204);
});

2. Use the cors middleware for all routes (Express.js shorthand)

const cors = require('cors');

const corsOptions = {
  origin: ['https://app.client.internal'],
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'X-Custom-Header'],
  maxAge: 600,
};

app.use(cors(corsOptions));
app.options('*', cors(corsOptions)); // handle preflight for all routes

3. Nginx — intercept OPTIONS at the edge

location /api/ {
  if ($request_method = 'OPTIONS') {
    add_header 'Access-Control-Allow-Origin' '$http_origin';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
    add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Custom-Header';
    add_header 'Vary' 'Origin';
    add_header 'Access-Control-Max-Age' '600';
    return 204;
  }
  proxy_pass http://backend_upstream;
}

4. Apache — respond to OPTIONS before backend logic runs


  Header set Access-Control-Allow-Origin "%{HTTP_ORIGIN}e" env=HTTP_ORIGIN
  Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
  Header set Access-Control-Allow-Headers "Authorization, Content-Type, X-Custom-Header"
  Header set Access-Control-Max-Age "600"
  Header set Vary "Origin"
  Header set Content-Length "0"
  Header set Content-Type "text/plain"
  RewriteRule .* - [R=204,L]

5. Clear the framework traps that swallow the OPTIONS request

Each of the four snippets above assumes the request actually reaches the handler you wrote. Several widely used stacks break that assumption in ways that produce the same console error:

// Express 5: a bare '*' is no longer a valid route path.
app.use(cors(corsOptions));
app.options('/{*splat}', cors(corsOptions));
# Nginx: without `always` these headers are dropped on 4xx and 5xx responses.
location /api/ {
  add_header 'Access-Control-Allow-Origin' '$http_origin' always;
  add_header 'Vary' 'Origin' always;
  proxy_pass http://backend_upstream;
}

Verification

Run this curl command to simulate the exact preflight the browser sends:

curl -si -X OPTIONS https://api.service.internal/data \
  -H 'Origin: https://app.client.internal' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: X-Custom-Header'

Check for all of the following in the response:

In DevTools, open the Network tab, enable Preserve log, reproduce the failing request, and filter by Fetch/XHR. You should see a separate OPTIONS row with a 204 or 200 status appearing immediately before your actual request row.

Not every 2xx passes, and not every failure looks different in the console. The Fetch Standard treats the preflight response as a permission grant only when the status is an ok status and the origin check on that same response succeeds; a redirect is rejected outright, because following it would let the server move the probe to a resource the browser never asked about. Four outcomes cover almost everything you will see:

Preflight outcomes by response status and headers Four rows pair an OPTIONS response with the browser's verdict. A 204 carrying Allow-Origin and Allow-Methods lets the actual request proceed; a bare 200, a 403 or 404 or 405, and a 301 or 302 redirect are all blocked before the real request is sent. What the browser accepts back from the OPTIONS probe Preflight response What it carries Browser verdict 204 No Content Allow-Origin and Allow-Methods present the actual request is sent 200 OK no Access-Control headers at all blocked before sending 403 / 404 / 405 the probe never reached the CORS layer blocked, generic error 301 / 302 a Location header instead of CORS headers blocked, redirect refused Only a 2xx that itself carries the Access-Control headers lets the real request proceed

The redirect row hides best of the four. A framework that normalises URLs by appending a trailing slash will happily redirect OPTIONS /data to OPTIONS /data/, and because the browser refuses to follow it the console prints the same generic wording as a missing header. Requesting the canonical URL from the client, or excluding OPTIONS from the redirect rule, removes that whole class of failure without touching your CORS configuration.

Security Boundary Note

Do not set Access-Control-Allow-Origin: * on endpoints that accept cookies or Authorization headers. The Fetch Standard prohibits credentials with wildcard origins — the browser will block the credentialed request even if the preflight passes. Reflect the exact validated origin using dynamic origin validation patterns instead, and always pair it with Vary: Origin to prevent CDN cache poisoning across different requesting domains (see handling the Vary: Origin header correctly for the full explanation).

Common Mistakes

Issue Technical explanation Impact
Returning 204 without CORS headers Browsers require Access-Control-Allow-Origin and Access-Control-Allow-Methods on the OPTIONS response itself, not just on the actual request response. Hard CORS block even when the subsequent request would otherwise succeed.
WAF or firewall silently drops OPTIONS Security appliances often classify OPTIONS as reconnaissance and block it without returning a response or returning a non-2xx status. The browser never receives a permission response; the actual request is never sent.
Omitting Vary: Origin Reverse proxies serve a cached OPTIONS response intended for one origin to all subsequent origins. Other origins receive a wrong or missing Access-Control-Allow-Origin, causing hard CORS blocks that are difficult to reproduce locally.
Returning 405 Method Not Allowed The framework has no route registered for OPTIONS on that path and falls through to a default method-not-allowed handler. Preflight fails with a 4xx; the browser treats this as a permission denial.
Redirecting the OPTIONS request Trailing-slash normalisation, HTTP-to-HTTPS upgrades and locale prefixes all answer the probe with a 3xx; the preflight fetch is defined never to follow a redirect. The probe is discarded and the console reports a generic CORS block with no mention of the redirect.
Handling OPTIONS behind the authentication filter The preflight is sent with credentials omitted, so the filter sees an anonymous request and answers 401. Every cross-origin call from an authenticated app fails, while the same endpoint works perfectly from curl with a token.

FAQ

Can I use GET or HEAD for preflight instead of OPTIONS?

No. Browsers strictly enforce OPTIONS for preflight per the CORS specification. GET and HEAD are reserved for simple requests and cannot safely query server method permissions without risking side-effects on state-mutating handlers.

Why does my server return 404 for OPTIONS requests?

The framework or web server lacks a route or handler for the OPTIONS method on that endpoint. Add an explicit route for OPTIONS, or apply a CORS middleware that automatically intercepts OPTIONS before application logic runs.

Does the OPTIONS preflight response get cached by the browser?

Yes, when the server returns Access-Control-Max-Age. Browsers cache the preflight result for the specified number of seconds. Chrome honors a maximum of 600 seconds (10 minutes); Firefox honors up to 86400 seconds (24 hours). Setting a non-zero Access-Control-Max-Age with cache-duration tuning eliminates redundant round-trips for identical origin/method/header combinations.

Does the OPTIONS preflight carry cookies or an Authorization header?

No. The Fetch Standard composes the preflight with its credentials mode set to omit, so no cookies, no Authorization header, and no client certificate accompany it — even when the actual request will be fully credentialed. That is deliberate: the probe asks a question about permission, so it must not itself depend on the permission being granted. The practical consequence is that any authentication filter sitting in front of your OPTIONS handler will reject its own preflight, which is why CORS middleware must be registered before the authentication layer in the request pipeline.

Why does my preflight fail only when the URL is missing a trailing slash?

Because the framework is answering the probe with a redirect rather than with CORS headers. Django’s APPEND_SLASH, Rails’ route normalisation, and most reverse-proxy canonicalisation rules turn OPTIONS /data into a 301 or 302 pointing at /data/. The preflight fetch is defined never to follow a redirect, so the browser discards the response and prints the same generic block message it uses for a missing header. Either request the canonical URL from the client or make the redirect rule skip the OPTIONS method.