Simple vs Preflight Requests: Mechanics, Configuration & Debugging
Cross-origin resource sharing (CORS) enforces strict browser-side boundaries for HTTP traffic. Understanding the operational boundary between Core CORS Mechanics & Same-Origin Policy Fundamentals is critical for secure API design and reliable frontend integration.
The browser evaluates each outbound request against a deterministic heuristic before execution. Misalignment between client expectations and server responses triggers opaque network failures.
Key operational boundaries include:
- Browser heuristic evaluation of HTTP method, headers, and
Content-Type - Server-side preflight handling via
OPTIONSandAccess-Control-Allow-*headers - Impact of
withCredentials: trueand custom headers on request classification - Systematic debugging steps for preflight failures and cache invalidation
Request Classification Heuristics
The WHATWG Fetch Standard defines a “simple request” as one that executes without prior negotiation. Browsers classify requests by evaluating three strict criteria. If any criterion fails, the request escalates to a preflight.
| Criterion | Allowed Values | Preflight Trigger |
|---|---|---|
| HTTP Method | GET, HEAD, POST |
Any other method (PUT, DELETE, PATCH) |
Content-Type |
text/plain, application/x-www-form-urlencoded, multipart/form-data |
application/json, text/xml, custom types |
| Request Headers | Only CORS-safelisted headers | Any header outside the safelist (e.g., Authorization, X-Custom-Header) |
The presence of withCredentials: true (or credentials: 'include') forces preflight evaluation regardless of method or content-type. This ensures explicit server consent before transmitting cookies or HTTP authentication.
For deeper context on how origin parsing dictates these triggers, review Origin Matching Rules & Validation.
Frontend Implementation Example:
// Simple Request: No preflight triggered
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'key=value'
});
// Preflighted Request: Triggers OPTIONS due to custom Content-Type
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
});
Preflight Negotiation Lifecycle
When a request fails the simple heuristic, the browser automatically intercepts execution and dispatches an OPTIONS request. This negotiation phase validates server permissions before transmitting sensitive payloads. The specification rationale for this HTTP verb is detailed in Why preflight requests use OPTIONS method.
HTTP Exchange Sequence:
- Browser Dispatch: Sends
OPTIONSwithOrigin,Access-Control-Request-Method, andAccess-Control-Request-Headers. - Server Validation: Evaluates origin against allowlist. Returns
204 No Contentor200 OKwith mandatory CORS headers. - Browser Evaluation: Validates
Access-Control-Allow-Origin,-Methods, and-Headers. ChecksAccess-Control-Max-Age. - Actual Request Execution: If validation passes, the browser dispatches the original request. Failure blocks execution.
Network Trace / curl Example:
# Preflight Request (Browser-generated)
OPTIONS /api/v1/resource HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, X-Trace-Id
# Server Response (Required for success)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, X-Trace-Id
Access-Control-Max-Age: 86400
Preflight caching mechanics significantly reduce latency. The Access-Control-Max-Age header dictates how long the browser caches this permission. Note that engine-level variations in cache duration and invalidation are documented in Browser-specific CORS implementation quirks.
Server Configuration Patterns
Production servers must explicitly handle OPTIONS routes. Relying on default routing often results in 404 Not Found or 405 Method Not Allowed. Implementations should balance security (explicit allowlists) with developer velocity (dynamic reflection).
| Strategy | Security Posture | Use Case | Risk |
|---|---|---|---|
| Static Allowlists | High | Public APIs, strict compliance | Maintenance overhead, header mismatch errors |
| Dynamic Reflection | Medium | Internal tools, rapid iteration | Potential header injection if unvalidated |
Wildcard (*) |
Low | Public read-only endpoints | Incompatible with credentials: 'include' |
Express.js Preflight Handler:
// Dynamic reflection with strict origin validation
app.options('*', (req, res) => {
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
// Safely reflect requested headers to avoid mismatch
res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] || '');
res.header('Access-Control-Max-Age', '86400');
}
res.sendStatus(204);
});
Nginx Configuration for Preflight Caching:
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $http_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
add_header 'Access-Control-Max-Age' 86400 always;
return 204;
}
proxy_pass http://backend_upstream;
}
Critical security constraint: Access-Control-Allow-Origin: * is strictly prohibited when Access-Control-Allow-Credentials: true is present. Browsers will block the response. Always reflect the exact Origin header when credentials are required, as outlined in Credential Sharing & Security Boundaries.
Cross-Origin Debugging Workflows
Preflight failures manifest as opaque network errors in the console. Systematic resolution requires isolating the negotiation phase from the actual request. Follow this DevOps-aligned workflow to diagnose failures efficiently.
Step-by-Step Diagnostic Protocol:
- Isolate the OPTIONS Request: Open DevTools Network tab. Filter by
Fetch/XHR. Locate theOPTIONSrequest. A404,405, or500status indicates missing server routing. - Validate Response Headers: Inspect the
OPTIONSresponse. VerifyAccess-Control-Allow-Originmatches the requesting origin exactly. Check for missing-Methodsor-Headers. - Identify Header Mismatches: Compare
Access-Control-Request-Headers(client) againstAccess-Control-Allow-Headers(server). Case-insensitive matching is required by spec. - Clear Stale Preflight Cache: If server configs changed but errors persist, force cache invalidation. In Chrome:
chrome://net-internals/#events&q=type:SOCKET. Alternatively, append a cache-busting query string during testing. - Evaluate Proxy vs Native CORS: If preflight complexity is unmanageable, route traffic through a same-origin reverse proxy. This eliminates browser CORS evaluation entirely.
Common Mistakes
- Omitting OPTIONS route handling: Servers returning
404/405onOPTIONSprevent preflight completion, causing the browser to block the actual request. - Using wildcard origins with credentials enabled: Browsers reject
Access-Control-Allow-Origin: *whenwithCredentials: trueis set, forcing explicit origin reflection. - Hardcoding preflight headers without dynamic reflection: Fails when frontend sends custom headers not explicitly listed in the server’s
Access-Control-Allow-Headers, triggering CORS errors. - Ignoring preflight cache invalidation: Changing allowed headers/methods without clearing browser cache causes stale
OPTIONSresponses to block new requests.
FAQ
Does a POST request always trigger a preflight?
No. POST only triggers preflight if it uses a non-standard Content-Type (e.g., application/json) or includes custom headers outside the CORS safelist.
How long does the browser cache a preflight response?
Controlled by Access-Control-Max-Age (in seconds). Defaults vary by browser, typically 24 hours if omitted. Chromium caps at 10 minutes for non-HTTPS, while Firefox defaults to 24 hours.
Can I disable preflight requests entirely?
Only by restricting requests to simple methods (GET, HEAD, POST), safe Content-Type values, and omitting custom headers/credentials. Architectural redesign or reverse-proxy routing is required otherwise.
Why does the preflight fail with a 200 OK but missing headers?
The browser requires specific CORS headers in the OPTIONS response. A generic 200 without Access-Control-Allow-Origin and related directives is treated as a failed negotiation, blocking the subsequent request.