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.
Prerequisite State
Before applying the fix below, confirm:
- Your server framework has a route or middleware layer that can match
OPTIONSrequests on the relevant path. - No upstream WAF rule blocks
OPTIONStraffic (common with security profiles that treatOPTIONSas a reconnaissance method). - The
Access-Control-Allow-Origindomain list on the server matches the exact origin (scheme + host + port) the browser sends.
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.
Related
- Simple vs Preflight Requests: CORS Mechanics — parent page covering the full request classification logic
- Designing Lightweight OPTIONS Endpoints — minimising latency in the OPTIONS handler itself
- How to Set Access-Control-Max-Age Effectively — cache the permission result to reduce preflight frequency
- Handling the Vary: Origin Header Correctly — prevent CDN cache poisoning on preflight responses
- Debugging Missing Access-Control-Allow-Origin Header — companion page for the other common preflight error