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.

Preflight OPTIONS request and response flow Diagram showing the browser evaluating a cross-origin fetch, issuing an OPTIONS preflight if needed, the server responding with CORS headers, and the browser caching the result before sending the real request. Browser cross-origin fetch() Simple request? Yes direct request No Preflight cache max-age valid? Hit real request Miss OPTIONS /endpoint + ACR-Method + ACR-Headers + Origin preflight Server / Proxy 204 No Content ACAO: origin ACAM: GET, POST ACMA: 600 Vary: Origin store in preflight cache Actual request sent POST / GET with full headers

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.