OPTIONS Endpoint Design for CORS Preflights
Without a correctly formed OPTIONS endpoint, every cross-origin non-simple request stalls: the browser issues a preflight and, if it receives a 403, 405, or malformed CORS headers, it blocks the actual request and presents a CORS error in the console. Even a technically correct response carries a latency penalty on every uncached preflight — meaning a slow or bloated OPTIONS handler adds a round-trip cost to every authenticated API call, file upload, or custom-header fetch your application makes.
This page is part of Preflight Request Optimization & Caching Strategies, which covers the full lifecycle from preflight suppression through cache expiry. Here the focus is narrower: how to structure the OPTIONS handler itself so it is fast, correct, and secure.
Spec anchor
The WHATWG Fetch specification (§3.2.2, “CORS-preflight fetch”) defines exactly what the browser sends and what it expects back. The browser constructs an OPTIONS request with three headers: Origin, Access-Control-Request-Method, and optionally Access-Control-Request-Headers. It then reads Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Max-Age from the response. Any divergence — missing header, wrong value, body with wrong content-type — does not cause a graceful fallback; it causes an outright block.
Key invariant from the spec: the browser never sends credentials on a preflight. No cookies, no Authorization header. An OPTIONS handler must never require authentication.
Response header reference
| Header | Type | Allowed values | Default if omitted | Browser cache limits |
|---|---|---|---|---|
Access-Control-Allow-Origin |
String | Exact origin or * |
None (preflight fails) | Not cached independently |
Access-Control-Allow-Methods |
Comma list | HTTP method names | None (preflight fails) | Cached with preflight result |
Access-Control-Allow-Headers |
Comma list | Header names (case-insensitive) | None (blocks custom headers) | Cached with preflight result |
Access-Control-Max-Age |
Integer (seconds) | 0–86400 | 5 seconds (browser default) | Chrome/Safari cap: 600 s; Firefox cap: 86400 s |
Access-Control-Allow-Credentials |
Boolean string | "true" only |
Not sent | N/A |
Vary |
Header list | Must include Origin |
CDN may serve wrong cached response | N/A |
Access-Control-Max-Age is the single largest lever for preflight performance. Setting it to 600 reduces Chrome and Safari preflight frequency to at most once every 10 minutes per origin+method+headers combination. Read the full analysis in Cache Duration Tuning & Max-Age.
Preflight request–response flow
The diagram below shows how a browser decides whether to issue a preflight, what the OPTIONS exchange looks like, and what happens to the cached result.
Step-by-step implementation
1. Intercept at the reverse proxy (Nginx)
For high-traffic services, intercepting OPTIONS before the request reaches the application server eliminates framework initialization overhead entirely. This is the proxy bypass approach applied at the OPTIONS layer.
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://backend;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
}
}
The map block performs origin validation once per connection in Nginx’s rewrite phase — faster and more predictable than if blocks inside location. The always flag ensures CORS headers are present even on error responses, which prevents double-failure scenarios where a 502 from the backend also strips the CORS headers and forces an opaque error in the browser.
2. Application-level handler (Express / Node.js)
When proxy-level handling is not available or you need per-route logic, handle OPTIONS in the application before authentication or business logic middleware:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
const ALLOWED_HEADERS = new Set([
'content-type',
'authorization',
'x-request-id',
]);
const ALLOWED_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
function corsPreflightHandler(req, res, next) {
const origin = req.headers.origin;
// OPTIONS must never require auth — short-circuit before any auth middleware
if (req.method !== 'OPTIONS') {
return next();
}
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
} else {
// Unknown origin: return 204 with no CORS headers — browser will block the real request
return res.status(204).end();
}
// Validate requested headers against allowlist before reflecting
const requested = (req.headers['access-control-request-headers'] || '')
.split(',')
.map(h => h.trim().toLowerCase())
.filter(h => ALLOWED_HEADERS.has(h));
res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS);
res.setHeader('Access-Control-Allow-Headers', requested.join(', '));
res.setHeader('Access-Control-Max-Age', '600');
return res.status(204).end();
}
// Mount before all other middleware, including auth
app.use(corsPreflightHandler);
The critical ordering constraint: corsPreflightHandler must be mounted before any authentication or session middleware. Auth middleware that returns 401 on an OPTIONS request breaks all preflights — the browser sees the 401 as a CORS failure and never retries.
3. FastAPI / Python (ASGI middleware)
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
ALLOWED_ORIGINS = {"https://app.example.com", "https://admin.example.com"}
ALLOWED_HEADERS = {"content-type", "authorization", "x-request-id"}
ALLOWED_METHODS = "GET, POST, PUT, DELETE, OPTIONS"
class CORSPreflightMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
origin = request.headers.get("origin", "")
if request.method != "OPTIONS":
response = await call_next(request)
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
return response
# Preflight path — never reaches application routes
if origin not in ALLOWED_ORIGINS:
return Response(status_code=204)
requested_raw = request.headers.get("access-control-request-headers", "")
requested = [
h.strip().lower()
for h in requested_raw.split(",")
if h.strip().lower() in ALLOWED_HEADERS
]
return Response(
status_code=204,
headers={
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": ALLOWED_METHODS,
"Access-Control-Allow-Headers": ", ".join(requested),
"Access-Control-Max-Age": "600",
"Vary": "Origin",
},
)
Add this middleware before AuthenticationMiddleware in your ASGI stack.
Edge cases and security boundaries
Subdomain isolation
Origin comparison is exact: https://api.example.com and https://app.example.com are distinct origins. Never use substring matching or suffix matching (endsWith('.example.com')) as an allowlist check — a domain like evil.notexample.com would pass a naive suffix test. Use a Set or array of complete origin strings.
The null origin
Requests from sandboxed iframes, data: URIs, or file:// documents send Origin: null. Never include null in your origin allowlist — doing so would allow any sandboxed page anywhere on the web to pass your preflight. If you need to support sandboxed iframes you control, use allow-same-origin on the sandbox attribute instead.
Opaque responses and no-cors mode
When the browser uses no-cors mode, it does not send a preflight and does not expose the response to JavaScript. Your OPTIONS handler will not be called in this scenario. The security boundary here is that the response is opaque — the page cannot read it, only trigger side effects.
Credentials and Access-Control-Allow-Origin: *
If your actual (non-OPTIONS) responses need to support credentialed requests, you must reflect the exact origin (not *) in Access-Control-Allow-Origin and include Access-Control-Allow-Credentials: true. The wildcard risks and mitigation page covers why * and credentials are mutually exclusive per the Fetch spec. This same constraint applies to the preflight response — setting * on OPTIONS while requiring credentials on the real request will cause the actual fetch to fail.
Auth middleware returning 401 on OPTIONS
The WHATWG Fetch spec (§3.2.2 step 7) requires the preflight response status to be in the range 200–299 for the preflight to succeed. A 401 from JWT or session middleware terminates the preflight. The fix is middleware ordering: the OPTIONS short-circuit must precede the auth stack entirely.
Proxy and CDN interaction
CDNs and shared reverse proxies will cache preflight responses if you include Cache-Control headers alongside Access-Control-Max-Age. However, the browser’s preflight cache is separate from the HTTP cache — Access-Control-Max-Age controls the browser-side preflight cache specifically. To avoid serving stale or cross-origin-polluted preflight responses from edge caches, always include Vary: Origin in OPTIONS responses.
Without Vary: Origin, a CDN may cache a preflight response for https://app.example.com and serve it in response to a preflight from https://admin.example.com. That response will contain the wrong Access-Control-Allow-Origin value, causing the second origin’s requests to fail. The header deduplication techniques page explains how to consolidate response headers to minimize Vary key explosion.
For CDNs that cache responses aggressively (Cloudflare, CloudFront, Fastly), explicitly configure your preflight routes to either bypass the cache (Cache-Control: no-store on OPTIONS) or ensure Vary: Origin is respected in your CDN’s cache settings — some CDN configurations strip Vary headers by default.
DevTools and curl verification checklist
Use this checklist after deploying changes to an OPTIONS handler:
Common mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Returning 200 OK with a JSON body on OPTIONS |
Increases payload size per preflight; some middleware may parse the body and fail; no functional benefit over 204 |
Return 204 No Content with no body |
Blindly echoing Access-Control-Request-Headers without allowlist validation |
Allows header injection — attacker can force arbitrary header names into Access-Control-Allow-Headers |
Validate each header against a server-side Set before reflecting |
Omitting Vary: Origin |
CDN or shared proxy serves one origin’s cached preflight response to a different origin, causing incorrect Allow-Origin values |
Always include Vary: Origin on every CORS response, including OPTIONS |
| Auth middleware running before OPTIONS handler | Any 401 or 403 from auth terminates the preflight; the browser sees a CORS failure |
Mount the OPTIONS short-circuit before all auth middleware |
Setting Access-Control-Max-Age above the browser cap |
Values above 600 s are silently clamped to 600 s in Chrome/Safari; the server believes caching is longer than it is | Use 600 for broad compatibility; use 86400 only for Firefox-only APIs |
Using * for Access-Control-Allow-Origin when credentials are needed |
The Fetch spec prohibits * with Access-Control-Allow-Credentials: true; the real request will fail even if the preflight succeeds |
Reflect the exact origin from a validated allowlist |
| OPTIONS route missing from framework routing config | Framework returns 405 Method Not Allowed; preflight fails before reaching your handler |
Explicitly register OPTIONS routes or use a global OPTIONS wildcard |
FAQ
Should OPTIONS endpoints return a 200 or 204 status code?
204 No Content is correct. The browser reads CORS headers from the preflight response and discards the body. Returning 200 with a JSON body adds bytes to every preflight round-trip for no gain and can confuse middleware that inspects response bodies.
Why does my browser keep sending OPTIONS even after I set Access-Control-Max-Age?
The preflight cache key includes the exact origin, method, and header combination. If any of those differ — even in casing — the browser sees a cache miss and issues a new preflight. Also confirm the value is within the browser’s internal cap (600 seconds for Chrome/Safari). Values above the cap are silently ignored. Check that Vary: Origin is set so CDN layers do not interfere with the browser’s ability to cache the preflight.
Can I handle OPTIONS at a reverse proxy instead of my application?
Yes. Nginx, HAProxy, and CDN edge functions can all intercept OPTIONS before the request reaches the application server. This is the recommended approach for high-throughput APIs — it eliminates framework boot, database pool initialization, and middleware stacks from the preflight path entirely.
Should I echo back the Access-Control-Request-Headers value unchanged?
No. Always filter the requested headers through a server-side allowlist. Reflecting the value verbatim lets a client inject arbitrary header names into Access-Control-Allow-Headers, potentially widening your CORS policy beyond what you intend.
What happens if auth middleware returns 401 on OPTIONS?
The browser treats any non-2xx response as a preflight failure and blocks the actual request. Because the Fetch spec guarantees that preflight requests never carry credentials, your auth middleware should be configured to pass OPTIONS through without checking tokens or session state.
Related
- Preflight Request Optimization & Caching Strategies — parent overview
- Cache Duration Tuning & Max-Age — setting
Access-Control-Max-Agecorrectly across browsers - Header Deduplication Techniques — consolidating headers to reduce
Varykey explosion - Proxy Bypass Strategies — offloading preflight handling to the network layer
- Designing Lightweight OPTIONS Endpoints — minimal-footprint handler patterns
- Dynamic Origin Validation Patterns — allowlist architecture for multi-origin APIs
- Wildcard Risks & Mitigation — when
Access-Control-Allow-Origin: *is unsafe