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.

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)

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]

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.

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.

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.