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:

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 non-safelisted header (e.g., Authorization, X-Custom-Header)

The CORS-safelisted request headers are: Accept, Accept-Language, Content-Language, Content-Type (with the above restrictions), Range (with simple range values), and a few others defined in the Fetch Standard. Any header outside this set triggers preflight.

The presence of credentials: 'include' (Fetch) or withCredentials = true (XHR) does not itself trigger a preflight — the method and header criteria still determine classification. However, when credentials are included, the server cannot respond with * and must reflect the exact origin.

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 application/json 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:

  1. Browser Dispatch: Sends OPTIONS with Origin, Access-Control-Request-Method, and Access-Control-Request-Headers.
  2. Server Validation: Evaluates origin against allowlist. Returns 204 No Content or 200 OK with mandatory CORS headers.
  3. Browser Evaluation: Validates Access-Control-Allow-Origin, -Methods, and -Headers. Checks Access-Control-Max-Age.
  4. Actual Request Execution: If validation passes, the browser dispatches the original request. Failure blocks execution entirely.

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: 600

Preflight caching significantly reduces latency. The Access-Control-Max-Age header dictates how long the browser caches this permission. Chrome honors up to 600 seconds (10 minutes); Firefox up to 86400 seconds (24 hours).

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');
    res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] || '');
    res.header('Access-Control-Max-Age', '600');
  }
  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' 600 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.

Step-by-Step Diagnostic Protocol:

  1. Isolate the OPTIONS Request: Open DevTools Network tab. Filter by Fetch/XHR. Locate the OPTIONS request. A 404, 405, or 500 status indicates missing server routing.
  2. Validate Response Headers: Inspect the OPTIONS response. Verify Access-Control-Allow-Origin matches the requesting origin exactly. Check for missing -Methods or -Headers.
  3. Identify Header Mismatches: Compare Access-Control-Request-Headers (client) against Access-Control-Allow-Headers (server). Matching is case-insensitive per spec.
  4. Clear Stale Preflight Cache: If server configs changed but errors persist, force cache invalidation. In Chrome: open DevTools, check “Disable cache” in the Network tab. Alternatively, use an incognito window.
  5. 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

FAQ

Does a POST request always trigger a preflight?

No. POST only triggers preflight if it uses a non-safelisted 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). Chrome honors up to 600 seconds (10 minutes); Firefox up to 86400 seconds (24 hours). If the header is absent, Chrome defaults to 5 seconds.

Can I disable preflight requests entirely?

Only by restricting requests to simple methods (GET, HEAD, POST), safelisted 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.