Skip to content

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.

The ProxifyEdge team 6 min read

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:

ConditionAllowed without preflight
MethodGET, HEAD or POST
Request headersOnly CORS-safelisted headers: Accept, Accept-Language, Content-Language, Content-Type (with the limits below) and a single-range Range
Content-Type valueapplication/x-www-form-urlencoded, multipart/form-data or text/plain
BodyNot a ReadableStream
XMLHttpRequestNo 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 JSON POST is always preflighted.
  • Authorization or any custom header such as X-API-Key or X-Request-ID.
  • PUT, PATCH and DELETE.
  • 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, HEAD and POST are implicitly allowed, but listing them does no harm.
  • Access-Control-Allow-Headers: must include every header in Access-Control-Request-Headers. A * wildcard works for requests without credentials, with one exception: it does not cover Authorization, 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:

  1. The cache is keyed per URL, so /v1/orders/1 and /v1/orders/2 each need their own preflight. Designs that put IDs in the path preflight more often than ones that don’t.
  2. 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 GET and 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-urlencoded or multipart/form-data instead 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 OPTIONS request the browser sends before any cross-origin request that isn’t “simple”: JSON bodies, custom headers and methods like PUT are the usual triggers.
  • The server must answer with a 2xx status and allow the origin, method and every requested header. * never covers Authorization.
  • Access-Control-Max-Age caches the answer, capped at two hours in Chromium and 24 hours in Firefox.
  • Preflights carry no credentials, so authentication middleware must let OPTIONS through.
  • Redirects on a preflight always fail.

Are you sure?