How to Design Lightweight OPTIONS Endpoints That Bypass Middleware
Failure symptom:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource.
(Reason: CORS preflight channel did not succeed)
Server logs show 401 Unauthorized or 504 Gateway Timeout on OPTIONS routes. Browser DevTools reports TTFB above 500ms for the preflight, or the preflight never completes at all.
Root Cause
The browser issues an OPTIONS preflight before any cross-origin request that uses a non-simple method or a custom header. The preflight carries no credentials — no cookies, no Authorization token. When a framework’s global middleware stack intercepts that OPTIONS request, it runs JWT validation, ORM initialization, or rate-limiting logic on a credential-less request. The result is either an immediate 401/403 (which the browser interprets as a CORS failure) or enough latency to exceed the browser’s preflight timeout. The fix is to register an explicit OPTIONS route that short-circuits the middleware chain before any of that logic executes.
This page is a focused implementation guide within OPTIONS Endpoint Design, which covers preflight routing architecture.
Prerequisite State
Before applying the patterns below, confirm the following:
- Your server already sends
Access-Control-Allow-Originon non-preflight responses (the endpoint is not missing CORS headers entirely). - You have identified the specific middleware — JWT validator, session loader, rate limiter — that is executing on
OPTIONSrequests in your current setup. - Your allowed origins list is defined and version-controlled; you will embed it directly in the preflight handler.
Step-by-Step Fix
Step 1 — Express.js: register the OPTIONS route above auth middleware
Place the app.options() call before any app.use() that loads authentication or business logic. Express evaluates routes in registration order, so position is the entire fix.
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
// Register BEFORE app.use(authMiddleware) and app.use(rateLimiter)
app.options('/api/*', (req, res) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '600');
res.sendStatus(204);
});
// Auth middleware runs only for non-OPTIONS routes
app.use(authMiddleware);
app.use(rateLimiter);
Step 2 — FastAPI/Starlette: define an explicit @router.options handler
FastAPI’s CORSMiddleware handles simple cases, but it still runs Depends() resolvers on every route. For endpoints that use token-authenticated Depends, add an explicit OPTIONS handler that returns before the dependency graph is evaluated.
from fastapi import APIRouter, Request, Response
router = APIRouter()
ALLOWED_ORIGINS = {
"https://app.example.com",
"https://admin.example.com",
}
@router.options("/resource")
async def preflight_resource(request: Request) -> Response:
origin = request.headers.get("origin", "")
headers = {
"Access-Control-Allow-Methods": "POST, PUT, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "600",
}
if origin in ALLOWED_ORIGINS:
headers["Access-Control-Allow-Origin"] = origin
headers["Vary"] = "Origin"
return Response(status_code=204, headers=headers)
Define this handler before the route that carries the authenticated Depends. FastAPI’s router resolves the first matching path handler.
Step 3 — Nginx: terminate preflight at the proxy layer
For the lowest possible latency, handle OPTIONS before the request ever reaches the application server. The map block below performs origin validation without the fragile if-in-location anti-pattern.
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' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
add_header 'Access-Control-Max-Age' 600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
proxy_pass http://backend_upstream;
}
}
return 204 inside the if block exits before proxy_pass, so the backend never receives the preflight request. Round-trip to the application server is eliminated entirely.
Verification
curl one-liner — run this against your endpoint and check the response before touching the browser:
curl -s -o /dev/null -w "%{http_code}" \
-X OPTIONS \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type, Authorization' \
https://api.example.com/resource
Expected output: 204
Full header check:
curl -X OPTIONS \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type, Authorization' \
-v https://api.example.com/resource 2>&1 | grep -E "< HTTP|< Access-Control|< Vary|< Content-Length"
DevTools checklist — open the Network tab, filter by Preflight, then verify:
Security Boundary Note
Do not reflect Access-Control-Request-Headers back verbatim without validating against your server-side allowlist. An attacker can send arbitrary header names in Access-Control-Request-Headers; blindly echoing them does not execute those headers but may allow unintended headers to be whitelisted for subsequent real requests. Always compare against an explicit Set or [] of headers your endpoint actually reads.
Similarly, never set Access-Control-Allow-Origin: * on an endpoint that will later carry credentials — the browser will reject the credentialed response even if the preflight succeeds. Use dynamic origin validation patterns to reflect the exact requesting origin after allowlist confirmation.
Common Mistakes
| Issue | Technical Impact | Mitigation |
|---|---|---|
| OPTIONS route registered after auth middleware | Auth middleware runs first, returns 401; browser treats this as a CORS failure | Move app.options() or its equivalent above all app.use() calls for auth |
Omitting Access-Control-Max-Age |
Browser issues a fresh preflight on every request; multiplies network overhead across SPA navigation | Set Access-Control-Max-Age: 600 (the Chrome/Safari cap); tune down to 60–300 for endpoints with dynamic header policies |
Returning 200 OK with a JSON body |
Browser does not reject it, but parsers process an unnecessary body; violates the intent of a lightweight preflight response | Return 204 No Content with an empty body |
Hardcoding Vary: Origin only on OPTIONS, not on the actual response |
CDN may serve a cached response with the wrong Access-Control-Allow-Origin to a different origin |
Add Vary: Origin on all CORS responses, not just the preflight; see header deduplication techniques |
FAQ
Should a lightweight OPTIONS endpoint return 200 or 204?
204 No Content is preferred. It signals successful preflight validation without transmitting a response body, minimising bandwidth and parse overhead. Some older Java servlet frameworks default to 200 — override this explicitly.
Does Access-Control-Max-Age apply to all HTTP methods at the same URL?
No. Browsers cache preflight results per the unique tuple of origin + URL + requested method + requested headers. Adding a new header to your fetch() call — even X-Request-ID — produces a cache miss and triggers a fresh preflight. Normalise the client’s header set to maximise cache reuse; see reducing preflight frequency with header caching for the full strategy.
How do I debug preflight timeouts in Chrome DevTools?
Open the Network tab, filter by Preflight, select the OPTIONS entry, and open the Timing panel. A large Stalled value indicates the connection is queued — often a saturated connection pool caused by auth middleware opening a DB connection. A large TTFB value with low Stalled indicates server-side processing time, pointing to middleware executing synchronously before the CORS headers are written.
Related
- OPTIONS Endpoint Design — parent: routing architecture and header validation patterns
- Cache Duration Tuning & Max-Age — set
Access-Control-Max-Ageto eliminate redundant preflights - Header Deduplication Techniques — consolidate response headers and prevent CDN cache fragmentation
- Proxy Bypass Strategies — terminate preflights at the edge before they reach your application
- How to Set Access-Control-Max-Age Effectively — per-browser limits and tuning guidance