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

  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.

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:

  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). Case-insensitive matching is required by spec.
  4. 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.
  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-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.