Simple vs Preflight Requests: CORS Mechanics

Without correct preflight handling, browsers silently block cross-origin API calls before a single byte of payload is transmitted — and the resulting console error gives almost no indication of which server header is missing. This page is part of Core CORS Mechanics & Same-Origin Policy Fundamentals and covers the exact classification rules the browser applies, the full negotiation lifecycle, per-stack server configuration, and a systematic debugging checklist.

The classification decision the browser makes before every cross-origin request

The WHATWG Fetch Standard (§3.1 “CORS-safelisted request-header”) defines a deterministic algorithm the browser runs on every outgoing cross-origin fetch. If the request passes all three criteria it is dispatched directly — no prior negotiation. If any criterion fails the browser intercepts the request and sends an OPTIONS preflight first.

Simple request vs preflight decision tree A decision tree showing how a browser classifies a cross-origin request: it checks the HTTP method, then the Content-Type, then whether any non-safelisted headers are present. All three must pass for a simple request; any failure routes to a preflight OPTIONS exchange. Cross-origin request Method is GET, HEAD, or POST? YES NO Content-Type safelisted? YES NO Only safelisted headers? YES NO OPTIONS preflight Simple request

Request classification reference table

Criterion Safelisted values Any value outside this list triggers preflight
HTTP method GET, HEAD, POST PUT, DELETE, PATCH, or any custom method
Content-Type text/plain, application/x-www-form-urlencoded, multipart/form-data application/json, text/xml, application/xml, any custom type
Request headers Accept, Accept-Language, Content-Language, Content-Type (restricted), Range (simple range values) Authorization, X-Request-Id, any X-* header, Cookie if set explicitly

The credentials: 'include' flag or withCredentials: true on XHR does not itself change the classification. The method and header criteria still govern whether a preflight fires. When credentials are present, however, the server must echo the exact Origin — it cannot respond with Access-Control-Allow-Origin: *.

Code examples: same payload, different classification

// Simple request — dispatched immediately, no preflight
fetch('https://api.example.com/search', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ q: 'cors' })
});

// Preflight required — application/json is not safelisted
fetch('https://api.example.com/search', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Trace-Id': 'abc123'           // non-safelisted header also triggers preflight
  },
  body: JSON.stringify({ q: 'cors' })
});

// Preflight required — PUT is not a safelisted method
fetch('https://api.example.com/resource/42', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ status: 'active' })
});

For the spec-level rationale behind why the browser uses OPTIONS for the preflight rather than a synthetic internal check, see Why Preflight Requests Use the OPTIONS Method.

Preflight negotiation lifecycle

When a request fails the simple-request test, the browser intercepts execution, constructs an OPTIONS request, and withholds the actual request until the server responds correctly. The entire sequence is invisible to application JavaScript — there is no callback, no event, no way to inspect or cancel it.

Preflight OPTIONS exchange sequence A sequence diagram showing three actors: Browser, Network/CDN, and Server. The browser sends an OPTIONS preflight with Access-Control-Request-Method and Access-Control-Request-Headers. The server responds with the matching Access-Control-Allow headers. The browser then sends the actual request and the server returns the data response. Browser Network / CDN Server OPTIONS /api/data · Origin · Access-Control-Request-Method: DELETE 204 · Access-Control-Allow-Origin · -Methods · -Headers · Max-Age Browser validates headers. Caches for Access-Control-Max-Age seconds. DELETE /api/data · Origin · Authorization: Bearer … 200 OK · Access-Control-Allow-Origin: https://app.example.com preflight actual

The four-step exchange

  1. Browser dispatches OPTIONS. The request carries Origin, Access-Control-Request-Method (the intended method), and Access-Control-Request-Headers (a comma-separated list of every non-safelisted header the actual request will send).
  2. Server validates and responds. The server must return 204 No Content or 200 OK with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. A missing or mismatched header is treated as denial.
  3. Browser evaluates the response. The browser checks that its requested method and headers are present in the server’s allow lists. It also reads Access-Control-Max-Age to determine how long to cache this grant.
  4. Browser dispatches the actual request. If validation passes, the real request fires with its full headers and body. If validation fails, the browser raises a network error and JavaScript sees only an opaque failure — the payload is never sent.

Preflight response header reference

Response header Type Allowed values Default if absent Browser cache cap
Access-Control-Allow-Origin string Exact origin or * — (required) n/a
Access-Control-Allow-Methods string Comma-separated HTTP methods — (required) n/a
Access-Control-Allow-Headers string Comma-separated header names — (required when non-safelisted headers sent) n/a
Access-Control-Max-Age integer (seconds) 0–86400 5 s (Chrome) Chrome: 7 200 s · Firefox: 86 400 s
Access-Control-Allow-Credentials boolean string "true" "false" n/a

Access-Control-Max-Age controls how long the browser reuses a cached preflight response before sending a fresh OPTIONS. Chrome’s hard cap is 7,200 seconds (2 hours); Firefox allows up to 86,400 seconds (24 hours). Setting the value beyond the browser’s cap has no additional effect. For a full treatment of tuning this header, see Cache Duration Tuning with Access-Control-Max-Age.

Step-by-step server implementation

1. Express / Node.js

import express from 'express';

const app = express();
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com'
]);

// Preflight handler — must match before any body-parsing middleware
app.options('*', (req, res) => {
  const origin = req.headers.origin;

  if (!ALLOWED_ORIGINS.has(origin)) {
    // Do not set any CORS headers; let the browser see a plain 403
    return res.sendStatus(403);
  }

  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
  // Echo back exactly what the browser asked for to avoid header mismatch
  const requestedHeaders = req.headers['access-control-request-headers'] || '';
  res.setHeader('Access-Control-Allow-Headers', requestedHeaders);
  res.setHeader('Access-Control-Max-Age', '600');
  res.setHeader('Vary', 'Origin');
  return res.sendStatus(204);
});

// Attach CORS headers to every actual response as well
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  next();
});

app.get('/api/data', (_req, res) => res.json({ ok: true }));
app.listen(3000);

Reflecting Access-Control-Request-Headers verbatim eliminates header-mismatch errors when the frontend adds new custom headers. Apply the same dynamic origin validation patterns to the actual-request middleware so the two stay in sync. Note that Vary: Origin is mandatory on any response whose Access-Control-Allow-Origin value depends on the incoming Origin — omitting it lets CDNs serve a cached response from one origin to a different one.

2. Nginx

# /etc/nginx/conf.d/api.conf

map $http_origin $cors_origin {
  default           "";
  "https://app.example.com"   $http_origin;
  "https://admin.example.com" $http_origin;
}

server {
  listen 443 ssl;
  server_name api.example.com;

  location /api/ {
    # Preflight branch
    if ($request_method = 'OPTIONS') {
      add_header 'Access-Control-Allow-Origin'  $cors_origin always;
      add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH' always;
      add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Trace-Id' always;
      add_header 'Access-Control-Max-Age'       600 always;
      add_header 'Vary'                         'Origin' always;
      return 204;
    }

    # Actual request — attach CORS headers here too
    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    add_header 'Vary' 'Origin' always;

    proxy_pass http://backend_upstream;
  }
}

The map directive computes $cors_origin once per request: an allowed origin passes through unchanged; anything else resolves to an empty string, so no Access-Control-Allow-Origin header is emitted. For multiple-origin scenarios, see Configuring CORS in Nginx for Multiple Origins.

3. Fetch API (service worker or edge middleware)

// Cloudflare Worker or Deno edge function
export default {
  async fetch(request) {
    const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
    const origin = request.headers.get('Origin') || '';

    if (request.method === 'OPTIONS') {
      if (!ALLOWED.has(origin)) {
        return new Response(null, { status: 403 });
      }
      return new Response(null, {
        status: 204,
        headers: {
          'Access-Control-Allow-Origin': origin,
          'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
          'Access-Control-Allow-Headers':
            request.headers.get('Access-Control-Request-Headers') || '',
          'Access-Control-Max-Age': '600',
          'Vary': 'Origin'
        }
      });
    }

    const response = await fetch(request);
    const modified = new Response(response.body, response);
    if (ALLOWED.has(origin)) {
      modified.headers.set('Access-Control-Allow-Origin', origin);
      modified.headers.set('Vary', 'Origin');
    }
    return modified;
  }
};

Edge cases and security boundaries

Credentials and the wildcard prohibition

When a request includes credentials: 'include' (Fetch) or withCredentials: true (XHR), the server must not respond with Access-Control-Allow-Origin: *. Browsers will block the response with a hard failure regardless of any other headers. The server must echo the exact Origin value. This rule applies to both the preflight response and the actual response — setting it correctly on one and using * on the other still causes a failure. Full credential isolation rules are covered in Credential Sharing & Security Boundaries.

Null origin from sandboxed iframes

An <iframe sandbox> without allow-same-origin causes the browser to send Origin: null for cross-origin requests originating inside it. Reflecting null in Access-Control-Allow-Origin is unsafe because any sandboxed context — including a malicious one — can generate a null-origin request. Validate null origins with extreme care or reject them outright.

Subdomain normalization

https://sub.example.com and https://example.com are different origins per the origin matching rules. A server that strips the subdomain and matches against the base domain will accept requests from any subdomain, including ones that may be compromised. Always match the full origin string.

Opaque responses

A fetch(url, { mode: 'no-cors' }) call returns an opaque response — the browser suppresses all headers and status. JavaScript cannot read the response body or status. Opaque responses do not trigger preflights, but they also provide no usable data. They are appropriate only for fire-and-forget side effects such as analytics pings.

The Access-Control-Request-Headers matching rule

Header name comparison in CORS is case-insensitive per the Fetch Standard. A server that returns Access-Control-Allow-Headers: authorization will satisfy a browser preflight that sends Access-Control-Request-Headers: Authorization. However, header list ordering is irrelevant — the browser matches by set membership, not position.

Proxy and CDN interaction

Preflight responses are HTTP responses like any other and are subject to CDN caching unless explicitly excluded.

What goes wrong: A CDN caches the first preflight response. The server’s allowlist is then updated to add a new origin. The CDN continues serving the stale cached preflight to requests from the new origin, which sees a response for the wrong origin value and fails.

Fix: Add Vary: Origin to preflight responses. This instructs the CDN to maintain a separate cache entry per requesting origin. Without it, the CDN folds all preflight responses into a single cache key. For a full treatment of Vary: Origin and deduplication strategies, see Header Deduplication Techniques.

Additional CDN considerations:

DevTools and curl verification checklist

curl -i -X OPTIONS https://api.example.com/api/data \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: DELETE' \
  -H 'Access-Control-Request-Headers: Authorization, X-Trace-Id'

Common mistakes

Issue Technical impact Mitigation
No OPTIONS route configured Server returns 404 or 405; preflight fails; browser blocks all requests using that method or headers Register an explicit OPTIONS handler before any body-parsing middleware
Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true Browser rejects the response with a hard failure; no amount of retrying fixes it Echo the exact Origin header value; never use * on credentialed routes
Hardcoded Access-Control-Allow-Headers list that doesn’t include a new header Frontend sends a header not in the server list; preflight fails with no indication of which header is missing Reflect Access-Control-Request-Headers back verbatim after validating against an allowlist
Missing Vary: Origin on preflight response CDN caches the response for one origin and serves it to a different origin; that second origin sees the wrong Access-Control-Allow-Origin value Add Vary: Origin unconditionally to all CORS responses
Stale browser preflight cache after allowlist change Developers change the server allowlist but browsers still honour the cached preflight and continue to block the updated set Lower Access-Control-Max-Age during development; instruct testers to disable cache in DevTools
Checking only preflight, not actual response Preflight succeeds; actual response omits Access-Control-Allow-Origin; browser still blocks Apply CORS headers in both the OPTIONS handler and the regular request middleware

FAQ

Does a POST request always trigger a preflight?

No. POST triggers preflight only when the Content-Type is not one of the three safelisted values (text/plain, application/x-www-form-urlencoded, multipart/form-data) or when the request includes non-safelisted headers such as Authorization. A plain form-encoded POST with no custom headers is a simple request and is dispatched without prior negotiation.

How long does a browser cache a preflight response?

The Access-Control-Max-Age header controls preflight cache duration in seconds. Chrome caps it at 7,200 seconds (2 hours); Firefox allows up to 86,400 seconds (24 hours). If the header is absent, Chrome defaults to 5 seconds. Setting a value above the browser’s cap has no additional effect.

Can preflight requests be eliminated entirely?

Only by restricting all cross-origin requests to simple methods (GET, HEAD, POST), safelisted Content-Type values, and no custom headers or credentials. Alternatively, routing traffic through a same-origin reverse proxy eliminates the cross-origin boundary entirely from the browser’s perspective, which is the approach described in proxy bypass strategies.

Why does the browser block a request even when the preflight returns 200 OK?

A 200 OK status is not sufficient. The browser requires Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers to be present and to match the request. Any missing or mismatched header causes the browser to treat the preflight as failed and block the actual request with an opaque network error.

Does adding credentials trigger a preflight on its own?

No. credentials: 'include' or withCredentials: true does not promote a request to preflight. The method and header criteria still govern classification. However, when credentials are present the server must return the exact Origin rather than *, and must include Access-Control-Allow-Credentials: true.