Skip to content
API2026-06-16Begin7 min read

What Is a CORS Preflight Request (OPTIONS)? A Cross-Origin Mechanism Every Frontend Developer Must Know

You call an API from the browser. The URL looks fine, the payload looks fine, but the console throws a red error:

Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: Response to preflight request doesn't pass
access control check: No 'Access-Control-Allow-Origin' header is present.

Worse: the Network panel shows an OPTIONS request you never wrote. That OPTIONS call is the CORS preflight. This article breaks down simple request vs preflight, the Allow-* response headers, credentials mode, and common pitfalls. To compare real responses, use the API Tester (browser direct is still subject to CORS; server proxy bypasses the browser's CORS checks) and the HTTP Status Codes lookup.

Same-origin, CORS, and preflight — keep the layers separate

The same-origin policy means a page can only freely read responses from the same scheme, host, and port. CORS (Cross-Origin Resource Sharing) is how a server tells the browser, via response headers, that a cross-origin response may be exposed to frontend script.

A preflight is not “an extra business call.” Before a non-simple request, the browser sends OPTIONS to ask whether the method and headers are allowed. If preflight fails, the actual request is never sent — which is why you often see a failed OPTIONS and no POST/PUT on the business path.

For Token + 401 + 405 stacked on CORS, see API testing: tokens and CORS errors.

Simple request vs preflight — trigger rules in detail

Spec language uses CORS-safelisted methods and headers. A request is “simple” (no preflight) only when all of the following hold:

DimensionSimple-request requirement
MethodOnly GET, HEAD, or POST
HeadersOnly safelisted headers (e.g. Accept, Accept-Language, Content-Language, plus a restricted Content-Type)
Content-Type (if set)Only text/plain, multipart/form-data, or application/x-www-form-urlencoded

Any break triggers a preflight. The usual frontend traps:

  • Methods such as PUT, DELETE, PATCH
  • Custom headers: Authorization, X-API-Key, X-Requested-With, …
  • Content-Type: application/json (almost every JSON API)
  • Other non-safelisted headers or non-safelisted values on otherwise safelisted headers
// Preflight: JSON Content-Type
fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'test' }),
});

// Preflight: non-simple method
fetch('https://api.example.com/users/1', { method: 'DELETE' });

// Preflight: custom header
fetch('https://api.example.com/data', {
  headers: { 'X-API-Key': 'abc123' },
});

Rule of thumb: classic form POSTs are often simple; modern fetch + JSON + Bearer tokens almost always preflight.

What the preflight sends — and what the server must return

The browser’s automatic OPTIONS looks roughly like:

OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
Request headerMeaning
OriginPage origin (scheme + host + port)
Access-Control-Request-MethodIntended business method
Access-Control-Request-HeadersNon-simple headers that will be sent (comma-separated; case-insensitive)

A typical approving response (status often 204 or 200; see the HTTP status codes guide):

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Vary: Origin

Access-Control-Allow-* cheat sheet

Response headerRoleCommon pitfall
Access-Control-Allow-OriginWhich origin may read the response (* or a concrete origin; see credentials)Missing; or production origin hardcoded while you debug on localhost
Access-Control-Allow-MethodsAllowed methodsOnly GET, POST while the app uses PUT/DELETE
Access-Control-Allow-HeadersAllowed request headersForgot Authorization / Content-Type
Access-Control-Max-AgeSeconds to cache the preflight resultStale cache after you fix the server
Access-Control-Allow-CredentialsWhether cookies / credentialed mode is allowedIncompatible with Allow-Origin: *
Access-Control-Expose-HeadersWhich response headers JS may read via getResponseHeaderAllow-* fixed but custom response headers still invisible

During preflight the browser mainly checks: Does Allow-Origin cover the current Origin? Does Allow-Methods include Request-Method? Does Allow-Headers cover every Request-Headers entry? Any mismatch → no business request.

The actual response (after a successful preflight) still needs a correct Access-Control-Allow-Origin (and Allow-Credentials when applicable). Fixing OPTIONS alone while omitting CORS headers on the real response still fails at “request left, body unreadable.”

Credentials mode: cookies and * cannot coexist

With:

fetch(url, { credentials: 'include' });
// or XHR: xhr.withCredentials = true

the browser may send cookies (and related auth state). CORS tightens:

  1. Server must send Access-Control-Allow-Credentials: true
  2. Access-Control-Allow-Origin must not be *; it must be an exact concrete origin (e.g. https://app.example.com)
  3. Both preflight and the actual response must satisfy the above

Typical console text: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Practice: match Origin against a whitelist, echo that origin, and add Vary: Origin so CDNs do not serve origin A’s response to origin B.

Common pitfalls (by how often they show up)

1. OPTIONS returns 405 / 404

Routes register POST /users but not OPTIONS /users. The browser hits 405 Method Not Allowed and preflight dies. Look up codes in the HTTP Status Codes tool. Fix: handle OPTIONS on the gateway or app (or use a solid CORS middleware).

2. Assuming “it’s just a POST”

POST can be simple — until you set JSON Content-Type. curl/Postman work; the browser fails. That gap is usually this layer.

3. Allow-Origin without Allow-Headers / Methods

Preflight asks for Authorization; the response never lists it → Request header field Authorization is not allowed by Access-Control-Allow-Headers.

4. Gateway strips CORS headers

The app adds headers; Nginx / CDN / API Gateway short-circuits OPTIONS or overwrites the response. Trust the headers the browser actually received, not only app logs.

5. Max-Age caches a bad preflight

You fix CORS; the browser still uses a cached preflight answer. Disable cache, use a private window, or shorten Max-Age in development.

6. Redirects across origins

A cross-origin 302 to another host makes CORS checks stricter along the redirect chain. Confirm the final URL and hop statuses (the API Tester helps inspect status codes and redirect behavior).

7. Mixed content mistaken for CORS

An HTTPS page requesting an http:// URL is blocked before send — not a CORS failure. The site’s API Tester separates this failure mode in direct mode so you do not chase the wrong server headers.

How to triage with tools on this site

  1. Pick the mode: the API Tester switches between browser direct and server proxy.
    • Direct: same constraints as page fetch (CORS / mixed content) — reproduce real frontend failures.
    • Proxy: the site’s server forwards the request — no browser same-origin preflight against the target; good for “does the API itself work?”
  2. Read the status: preflight often 204/200; failures often 403/404/405. Use HTTP Status Codes when unsure.
  3. Send OPTIONS yourself: choose OPTIONS in the tester and inspect whether Access-Control-Allow-* is complete.
  4. When Token + 401 stack on CORS: clear preflight first, then auth — see tokens and CORS.

Production still needs real CORS config. Proxy mode is a debug aid; end users’ browsers will not use your debug proxy.

Temporary workarounds when you cannot change the server

  1. Dev proxy: Vite / webpack devServer.proxy so /api is same-origin to the browser.
  2. This site’s server proxy: enable proxy in the API Tester to validate API logic.
  3. Browser extension to disable CORS: local development only — never for daily browsing.
// Vite proxy example
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
};

Bottom line

Non-simple request → browser OPTIONS preflight → server approves via Access-Control-Allow-* → then the business request; the business response needs CORS headers too. In Network, align Access-Control-Request-* with Access-Control-Allow-*. To confirm the API works, use the API Tester proxy mode; to reproduce the page failure, use direct mode. Look up statuses anytime with HTTP Status Codes.

Implementer's note

On network-layer failures that look like CORS, the API Tester shows a hint and offers “enable server proxy and retry.” The proxy path hits same-origin /api/proxy and the server performs the upstream call, so the browser never preflights the target — similar to curl/Postman, and not a production frontend fix. An earlier revision of this article said the tester was “never subject to CORS,” which contradicted direct mode; the accurate split is direct constrained / proxy bypass. During the AdSense freeze we do not add new tool slugs; preflight-related workflows stay on the existing api-tester and http-status-codes tools.


Provided by ToolVault. Related tools: API Tester, HTTP Status Codes, HTTP Method Cheatsheet, curl-to-code. See the homepage for more developer tools.