GuidesUNDERSTAND
The CORS preflight request
Browsers enforce cross-origin security by verifying that a target server explicitly permits an interaction before initiating it. A CORS preflight is an automated handshake the browser sends to inspect server permissions whenever an outgoing call exceeds standard safelisted criteria. If the preflight probe fails, the browser cancels the real request entirely before it reaches the wire.
What a preflight is
A CORS preflight is an HTTP OPTIONS request dispatched automatically by the browser prior to the actual cross-origin request. Its purpose is to check whether the target server permits the intended HTTP method and custom headers from the calling web origin. Front-end code never constructs or dispatches this request manually.
The probe carries an Origin header, an Access-Control-Request-Method header containing the intended method, and, when non-safelisted headers are present, an Access-Control-Request-Headers header listing their names. Preflight requests never include credentials such as cookies or the Authorization header. When a preflight fails due to a non-2xx status code, an HTTP redirect, or missing access-control headers, the browser cancels the operation, ensuring the real request is never sent.
When the browser sends one
The browser skips the preflight exchange only when a request qualifies as CORS-safelisted. A request loses this exemption and triggers an OPTIONS preflight if any of the following conditions are met:
- The HTTP method is anything other than GET, HEAD, or POST.
- The request includes any header outside the CORS-safelisted set, such as
Authorizationor a customX-header. - The
Content-Typeheader is set to any value other thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain, which makesapplication/jsonan everyday trigger. - The body used in the request is a
ReadableStream. - One or more event listeners are registered on an
XMLHttpRequestUploadobject.
Anatomy of a preflight exchange
Before dispatching a request that fails the safelist checks, the browser asks the target server for permission with an OPTIONS request that describes the intended operation.
// This fetch is not safelisted: PUT plus a JSON Content-Type
await fetch('https://api.example.com/users/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ada' }),
});
// 1. The browser sends this first, on its own, with no cookies:
> OPTIONS /users/42 HTTP/2
> origin: https://app.example.com
> access-control-request-method: PUT
> access-control-request-headers: content-type
// 2. The server must answer 2xx with matching permissions:
< HTTP/2 204
< access-control-allow-origin: https://app.example.com
< access-control-allow-methods: PUT
< access-control-allow-headers: content-type
< access-control-max-age: 600
// 3. Only now does the real PUT leave the browser.A passing preflight response must return a 2xx status code, where 204 No Content is conventional, and include an Access-Control-Allow-Origin header matching the request origin or *. It must also return Access-Control-Allow-Methods covering the requested method and Access-Control-Allow-Headers covering every header name declared in Access-Control-Request-Headers. Including Access-Control-Max-Age is optional. The cors.dev free tier accepts GET and HEAD requests only, which are safelisted methods; managed access also supports write methods and custom API headers that trigger preflights.
Caching preflights with Access-Control-Max-Age
Preflight responses are stored in a dedicated browser preflight cache that exists separately from the standard HTTP cache. When a server omits the Access-Control-Max-Age response header, the browser defaults to caching the preflight validation result for only 5 seconds, causing frequent repeat OPTIONS requests during active user sessions.
Servers can instruct the browser to reuse the preflight result by specifying a duration in seconds using Access-Control-Max-Age. However, browser engines impose internal maximum caps on this value regardless of higher limits requested by the server. Because WebKit clamps the value to 600 seconds, 10 minutes acts as the practical upper ceiling across general consumer traffic.
| Engine | Max-Age cap |
|---|---|
| Firefox | 86400 seconds (24 hours) |
| Chromium (Chrome, Edge) | 7200 seconds (2 hours) |
| WebKit (Safari) | 600 seconds (10 minutes) |
Debugging a failing preflight
A preflight failure prevents the actual request from executing, leading to client errors that do not appear in application backend routes. Use these steps to isolate the point of failure:
- Locate the OPTIONS entry in the DevTools Network panel, inspect its HTTP status code, and check that the server returned the necessary access control response headers.
- Verify whether backend server logs recorded incoming traffic, keeping in mind that when the preflight fails, the real request is blocked by the browser and never leaves the machine.
- Confirm that upstream authentication middleware is not intercepting and rejecting the preflight probe, since OPTIONS preflights never carry credentials or an
Authorizationheader. - Check that the server's
Access-Control-Allow-Headersresponse header explicitly lists every single header declared by the browser insideAccess-Control-Request-Headers.
Good questions.
Can I disable the preflight from JavaScript?
No. The browser triggers preflight requests automatically based on security rules. The only way to avoid a preflight is to restructure the request so it stays within the CORS-safelisted method and header constraints.
Why does my server log show no request even though the code ran?
When a preflight request fails its validation check, the browser blocks the real request entirely. The actual request is never sent over the wire, so your server never receives it.
Does the preflight send my cookies?
No. Preflight requests never carry credentials, meaning the browser strips cookies and omits the Authorization header from the initial OPTIONS call.
How do I stop an OPTIONS probe before every request?
Return the Access-Control-Max-Age header in your preflight response. Without it, the browser defaults to a 5-second cache lifetime, though browser caps will limit caching to 600 seconds in WebKit and 7200 seconds in Chromium.