CORS preflight requests explained: when and why OPTIONS happens
What triggers a CORS preflight request, how the browser and server negotiate it, how Access-Control-Max-Age caches it, and how to fix the failures you'll see.
You add one custom header to a fetch call and suddenly the Network panel shows two requests: an OPTIONS request you never wrote, followed (if you’re lucky) by the one you did. That extra request is a CORS preflight request, and when it fails, your real request never leaves the browser. This guide explains exactly what triggers a preflight, what the browser and server say to each other, and how to fix the errors it produces.
If you’re new to CORS itself, start with What is CORS? and come back. This article assumes you know that the browser enforces CORS and the server opts in with Access-Control-* response headers.
What a CORS preflight request is
A preflight is a permission check. Before the browser sends a cross-origin request that could have side effects a 1990s HTML form couldn’t have produced, it asks the server first: “I’m about to send a PUT with an Authorization header from https://app.example.com. Is that allowed?”
The question is an OPTIONS request with no body and a few special headers:
OPTIONS /v1/orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
The server answers with what it permits:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin
If every part of the planned request is covered, the browser sends the real PUT. If anything is missing, the browser stops, logs a CORS error, and your code sees a TypeError: Failed to fetch with no response to inspect.
The reason preflights exist is backwards compatibility. Servers written before CORS assumed browsers could only send “form-like” cross-site requests. A preflight guarantees those servers never receive anything more powerful than that without explicitly agreeing to it.
Simple requests vs requests that need a preflight
The Fetch standard defines which requests skip the preflight. Older articles call these “simple requests.” A request is sent without a preflight only when all of these are true:
| Condition | Allowed without preflight |
|---|---|
| Method | GET, HEAD or POST |
| Request headers | Only CORS-safelisted headers: Accept, Accept-Language, Content-Language, Content-Type (with the limits below) and a single-range Range |
Content-Type value | application/x-www-form-urlencoded, multipart/form-data or text/plain |
| Body | Not a ReadableStream |
XMLHttpRequest | No event listeners registered on xhr.upload |
Anything else triggers a preflight. In practice, these are the usual causes:
Content-Type: application/json. The single most common trigger. A JSONPOSTis always preflighted.Authorizationor any custom header such asX-API-KeyorX-Request-ID.PUT,PATCHandDELETE.- Headers added for you by a library or interceptor, such as an Axios instance with default headers.
Note that the browser decides this from the request you build, not from the server’s capabilities. You can’t “turn off” a preflight from the server side; you can only answer it correctly or change the request.
The headers involved
What the browser sends
Origin: the scheme, host and port of the page making the request.Access-Control-Request-Method: the method of the real request.Access-Control-Request-Headers: a comma-separated, lowercase list of the non-safelisted headers the real request will carry.
What the server must answer
Access-Control-Allow-Origin: the requesting origin (or*when no credentials are involved).Access-Control-Allow-Methods: must include the requested method.GET,HEADandPOSTare implicitly allowed, but listing them does no harm.Access-Control-Allow-Headers: must include every header inAccess-Control-Request-Headers. A*wildcard works for requests without credentials, with one exception: it does not coverAuthorization, which must always be named explicitly.Access-Control-Allow-Credentials: true: only if the real request uses cookies or other credentials. See CORS with credentials.Access-Control-Max-Age: optional, how long the browser may cache this answer.
The preflight response must also have an OK status (any 2xx). A 401, 404 or 500 on the OPTIONS request fails the check even if every header is perfect.
Caching preflights with Access-Control-Max-Age
Without caching, every preflighted request costs two round trips. Access-Control-Max-Age tells the browser how many seconds it may reuse a preflight result for the same origin, URL and request shape.
Access-Control-Max-Age: 7200
Browsers cap the value you send. Chromium-based browsers cap it at two hours (7200 seconds) and Firefox at 24 hours. When the header is absent, browsers cache the result for only about five seconds, which means a busy single-page app may preflight almost every call.
Two practical consequences:
- The cache is keyed per URL, so
/v1/orders/1and/v1/orders/2each need their own preflight. Designs that put IDs in the path preflight more often than ones that don’t. - When you change your CORS configuration, clients may keep using a cached answer until it expires. Keep the value modest while you’re still iterating.
Common preflight failures and their fixes
The browser console tells you which check failed. These are the messages you’ll meet most often in Chrome, with the cause and the fix.
“Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present”
The server answered OPTIONS without CORS headers. Frameworks often route OPTIONS to a 404 or 405 handler that skips the CORS middleware. Make sure your CORS middleware runs before routing and handles OPTIONS for every path.
“It does not have HTTP ok status”
The preflight reached an authentication check and got a 401 or 403, or hit a route that doesn’t exist. Preflights never carry credentials or custom headers, so they can’t pass authentication. Exempt OPTIONS from auth middleware and answer it with 204.
“Request header field x-api-key is not allowed by Access-Control-Allow-Headers in preflight response”
The header named in the message isn’t in Access-Control-Allow-Headers. Add it, or stop sending it. Remember that Authorization is never covered by *.
“Method PUT is not allowed by Access-Control-Allow-Methods in preflight response”
Add the method to Access-Control-Allow-Methods.
“Redirect is not allowed for a preflight request”
The OPTIONS request was redirected, often from http to https or from a path without a trailing slash to one with it. Browsers don’t follow redirects on preflights. Call the final URL directly.
When the message is ambiguous, the Network panel shows the preflight as its own row. Our step-by-step guide to debugging CORS errors in DevTools walks through reading it.
Avoiding preflights where it makes sense
Sometimes the cheapest fix is a request that doesn’t need a preflight at all:
- For read-only calls, use
GETand drop custom headers. Move a token from a header into the query string only if the API supports it and the token is safe to appear in logs and browser history (most aren’t). - Send form data as
application/x-www-form-urlencodedormultipart/form-datainstead of JSON, if the API accepts it. - Put the API on the same origin as your frontend, behind a backend-for-frontend or a reverse proxy, so no CORS check happens at all.
Don’t contort a clean API to save a round trip, though. A well-cached preflight costs one request every couple of hours.
When you don’t control the API
If the API you’re calling doesn’t answer preflights, nothing you do in the browser will fix it. You need something that answers on the API’s behalf.
ProxifyEdge answers the preflight itself. You call https://api.proxifyedge.com/proxy?url=<encoded target> with your key in the X-API-Key header, and ProxifyEdge returns the Access-Control-* headers your key’s origin list allows. Preflights aren’t counted against your quota. The playground lets you send a preflighted request and inspect both responses, and the request reference lists exactly which headers are forwarded.
Key takeaways
- A preflight is an
OPTIONSrequest the browser sends before any cross-origin request that isn’t “simple”: JSON bodies, custom headers and methods likePUTare the usual triggers. - The server must answer with a
2xxstatus and allow the origin, method and every requested header.*never coversAuthorization. Access-Control-Max-Agecaches the answer, capped at two hours in Chromium and 24 hours in Firefox.- Preflights carry no credentials, so authentication middleware must let
OPTIONSthrough. - Redirects on a preflight always fail.