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.

Three further invariants are easy to violate by accident. First, a preflight response must not be a redirect: the algorithm treats a 3xx on the CORS-preflight fetch as a network error rather than following the Location, which is why an HTTP-to-HTTPS canonicalisation rule or a trailing-slash rewrite in front of your API turns every non-simple request into an unexplained failure. Second, the response body is never parsed, so its Content-Type is irrelevant — but a body still costs bytes and, on some frameworks, triggers a content-negotiation code path that can fail. Third, the browser matches Access-Control-Allow-Methods and Access-Control-Allow-Headers case-insensitively for header names and case-sensitively for method names, so post in an allow list does not authorise POST.

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.

Mount position is the whole story here — the same four pieces of middleware succeed or fail purely on the order they were registered in:

Middleware mount order decides whether a preflight survives Two horizontal chains of the same four middleware stages. In the upper chain the CORS preflight handler is mounted first and answers with 204 before authentication runs. In the lower chain authentication is mounted first and returns 401, so the preflight handler never executes. The same four handlers, registered in two different orders app.use(corsPreflightHandler) mounted first OPTIONS /v1/orders preflight handler matches, replies 204 auth middleware not executed route handler not executed 204 No Content — one hop, no framework work app.use(requireJwt) mounted first OPTIONS /v1/orders auth middleware no token on a preflight preflight handler never runs route handler never runs 401 — the browser reports it as a CORS failure

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.

Redirects on the preflight route

Because a preflight may not be redirected, any infrastructure rule that rewrites the request URL is a hazard on OPTIONS specifically. The three that bite most often are an HSTS-style httphttps upgrade at the load balancer, a trailing-slash normaliser that answers /v1/orders with a 301 to /v1/orders/, and a locale or tenant prefix injected by an edge rule. Each is harmless for a same-origin browser navigation and fatal for a cross-origin preflight. Exempt the OPTIONS method from those rewrites, or make the client call the already-canonical URL so no redirect is ever generated.

The * wildcard inside Access-Control-Allow-Headers

Access-Control-Allow-Headers: * looks like a shortcut but changes meaning depending on credential mode. For a non-credentialed request the browser treats * as a wildcard covering any requested header. For a credentialed request it is interpreted as the literal header name *, which matches nothing, so every custom header is rejected and the preflight fails with a message naming the header that was not allowed. The Authorization header is a further special case: several engines refuse to cover it with a wildcard even without credentials, so name it explicitly whenever your API accepts bearer tokens.

Private Network Access preflights

Chromium sends an additional preflight, carrying Access-Control-Request-Private-Network: true, when a public-origin page requests a resource on a private or loopback address — the classic case being a production web app talking to http://localhost:8080 during local development. The handler must answer that preflight with Access-Control-Allow-Private-Network: true in addition to the usual headers, or the request is blocked with a message that mentions private network access rather than a missing origin. Treat it as a distinct branch in the OPTIONS handler and gate it on the request header being present, so public-to-public traffic never receives the header.

Framework routing that swallows OPTIONS

Several stacks answer OPTIONS before your handler ever sees it. Express’s router replies to an unmatched OPTIONS with an automatic Allow header and a 200, which passes the status check but carries no CORS headers at all — the symptom is a preflight that “succeeds” while the real request is still blocked. Spring Boot’s @CrossOrigin annotation only applies to mapped handler methods, so a route that exists solely for POST gets a 405 on its preflight unless DispatcherServlet is configured with dispatchOptionsRequest. ASP.NET Core evaluates UseCors strictly in pipeline order and silently does nothing if it is registered after UseAuthorization. Django’s django-cors-headers must appear above CommonMiddleware, because CommonMiddleware’s APPEND_SLASH behaviour generates exactly the redirect a preflight cannot follow.


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.

The difference is entirely in what the edge uses as its cache key, and it shows up as a single poisoned object serving two applications:

What Vary: Origin changes about the edge cache key Two panels of cache entries. On the left the key omits Origin, so the second application receives the first application's Access-Control-Allow-Origin and is blocked. On the right the key includes Origin, so each application gets its own stored preflight and both succeed. Vary: Origin absent Vary: Origin present key = URL + method app.example.com preflights first stored ACAO: https://app.example.com admin.example.com preflights next edge answers HIT from the same object admin receives the app origin echo origin mismatch — preflight rejected one object, two broken applications key = URL + method + Origin app.example.com stores entry one ACAO: https://app.example.com admin.example.com stores entry two ACAO: https://admin.example.com every origin keeps its own entry both preflights succeed on a HIT cost: one cached object per origin Access-Control-Max-Age governs the browser's private cache; Vary governs every shared cache in between

Vary is a blunt instrument, so keep the list minimal. Each additional header named in Vary multiplies the number of stored objects at the edge, and a preflight route that varies on Origin, Accept-Encoding, and Access-Control-Request-Headers at once can shard a single logical response into dozens of near-identical cache entries with a correspondingly poor hit rate. Vary on Origin alone unless you have measured a concrete need for more, and normalise the value server-side — lowercase the scheme and host, strip any default port — before it becomes part of a key.

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.

Does the OPTIONS response need Access-Control-Allow-Credentials?

Yes, whenever the real request will be sent with credentials: 'include' or withCredentials. The browser checks the credentials flag on the preflight response as well as on the actual response, so omitting Access-Control-Allow-Credentials: true from the OPTIONS reply fails the preflight even though the preflight itself carried no cookies. The paired constraint also applies — the preflight must echo an exact origin rather than a wildcard whenever credentials are enabled.

Why does my preflight only fail in production, behind the load balancer?

The usual cause is a redirect that only exists in front of the production deployment: an http-to-https upgrade, a trailing-slash normaliser, or a host canonicalisation rule. A CORS preflight is not allowed to follow a redirect, so a 301 or 308 on the OPTIONS request is treated as a network error. Run curl -si -X OPTIONS against the production URL and check for a 3xx status and a Location header before you touch any Access-Control configuration.