CORS Preflight Failed: Fix OPTIONS Response
A failed CORS preflight means your OPTIONS reply lacked the right headers.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Basic fetch API and HTTP methods
- ✓A backend you can configure
- ✓Browser DevTools Network familiarity
- A preflight is an OPTIONS request the browser sends before your real call when you use JSON bodies, custom headers, or methods like PUT and DELETE.
- Your server must answer OPTIONS with a 2xx status plus Access-Control-Allow-Methods and Access-Control-Allow-Headers, before any auth middleware runs.
- A missing OPTIONS row in the Network tab means the main request failed instead. A red OPTIONS row means the preflight itself failed.
- Keep preflights unauthenticated, echo validated origins, and test with an origin-carrying curl OPTIONS call after every change.
Imagine a courier who phones the front desk before driving over with a special delivery. The call states the package type and asks which entrance to use. If the desk answers with clear instructions, the courier drives over. If nobody picks up, the delivery never leaves the depot. The preflight is that phone call, your server is the front desk, and the browser is a strict courier who cancels the trip when the instructions are missing. Fix the front desk's answers and deliveries resume.
Your GET requests sail through, your POST with a JSON body dies, and the console blames CORS. The Network tab shows a lonely OPTIONS request glowing red while your real request never leaves the browser. Nothing is wrong with your fetch call. The browser asked your server for delivery instructions, got silence or a wrong answer, and cancelled the trip before starting it.
That cancelled trip is a failed preflight, and it deserves its own guide because its causes barely overlap with main-request CORS failures. A missing Allow-Origin on a GET is one fix. A 401-gated OPTIONS route, a framework that never registered an OPTIONS handler, or an Nginx block that proxies OPTIONS upstream instead of answering it are entirely different bugs with different owners.
This guide focuses on the preflight exchange only. You will learn which requests trigger one, how to tell a preflight failure from a main-request failure in the Network tab, the exact headers and status a correct OPTIONS answer carries, and how to wire that answer in Express, Spring, and Nginx. Pair it with the general CORS policy article for main-request failures.
What Triggers a Preflight: The Three Rules That Matter
Browsers preflight a cross-origin request when it is not simple, and simple has a strict definition. The method must be GET, HEAD, or POST. The headers must stay within the safelist: Accept, Accept-Language, Content-Language, Content-Type, and Range with limits. And the Content-Type must be a form-like value: application/x-www-form-urlencoded, multipart/form-data, or text/plain. Step outside any of the three and the browser sends an OPTIONS probe first.
In practice, three triggers cover nearly every failure. A JSON body sets Content-Type to application/json, which is outside the safelist, so every JSON POST is preflighted. Custom headers such as Authorization, X-Request-Id, or X-CSRF-Token break the header rule even on GET requests. And the verbs PUT, PATCH, and DELETE break the method rule regardless of headers. If your GETs pass and everything else fails, you are staring at one of these three.
This selectivity explains the confusing partial outages. A dashboard that reads with GET keeps working while its save buttons, which PUT JSON, all die. A public endpoint without auth passes while the authenticated one fails, because only the second sends Authorization. The pattern of what works is the diagnosis: find what the failing calls share and you have named your trigger.
The snippet below shows the most common trigger in the wild, a JSON POST, alongside a simple form POST that skips the probe. Run them against any test API with the Network tab open and watch one produce an OPTIONS row while the other does not.
Reading the OPTIONS Exchange in the Network Tab
The Network tab tells the whole preflight story in two rows. The first row is the OPTIONS probe carrying Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. The second row, present only when the probe passes, is your real request. A failed preflight shows the OPTIONS row in red and no second row at all, because the browser cancelled the trip. That missing second row is the single most reliable signal you are debugging a preflight and not a main-request failure.
Click the OPTIONS row and read three things in order. The status code: anything outside 2xx fails the probe, and 401 or 405 each point at a specific layer. The Response Headers: Allow-Methods and Allow-Headers must be present with values covering your call. And the timing: a 2 ms rejection means edge middleware killed it, while a full round trip suggests the app itself answered badly.
Request headers deserve equal attention. Access-Control-Request-Headers lists exactly what the browser asked to send, so compare it token by token against the Allow-Headers reply. Developers often spot the gap here in seconds: the client sends authorization plus X-Request-Id while the server allows only content-type. The console message names the rejected header too, but the two header blocks side by side remove all doubt.
Finish by checking the console message against what you found. A disallowed-method message with a 204 OPTIONS means Allow-Methods is short. A disallowed-header message with 200 means Allow-Headers is short. A redirect or 401 on OPTIONS means routing or auth. Each pairing maps to one fix in a later section.
The Four Headers Your OPTIONS Answer Must Return
A correct preflight answer is small and exact. The status is 2xx, with 204 No Content as the convention since there is no body to return. Access-Control-Allow-Origin echoes the requesting origin or names one allowed origin. Access-Control-Allow-Methods lists the verbs the endpoint accepts, such as GET, POST, PUT, DELETE, OPTIONS. Access-Control-Allow-Headers lists every custom header the client may send. With credentials in play, Access-Control-Allow-Credentials true joins them, plus Vary: Origin so caches keep per-origin answers apart.
Each missing piece fails differently. No Allow-Methods fails PUT and DELETE while GET passes. No Allow-Headers fails every call carrying the unlisted header, with the console naming it. A wildcard origin fails credentialed calls outright. The browser checks all of them against the probe's request headers, so partial answers produce partial outages that look random until you line up the headers.
Access-Control-Max-Age deserves a deliberate value. It tells the browser how many seconds to cache the probe result, turning one OPTIONS exchange into minutes of direct calls. A value like 600 keeps chatty apps fast. During development, though, the cache hides your fixes, so test with DevTools cache disabled or you will conclude a working fix is broken.
The snippet below is a dependency-free Node server that answers OPTIONS correctly and echoes a validated origin. It runs with plain node server.js and shows the minimal shape every framework config must reproduce: early OPTIONS return, 204 status, and the four headers before any other logic.
Preflight Failure vs Main-Request Failure: Telling Them Apart
These two failures print similar console text but need opposite fixes, so telling them apart is the highest-value skill in this guide. A preflight failure shows a red OPTIONS row and no real request row. The fix lives in routing and middleware order. A main-request failure shows the real GET or POST row with a response that lacks Allow-Origin. The fix lives in response headers. Mixing them up sends you editing response headers for a request that never left.
Status codes sharpen the split. On the OPTIONS row, 401 means auth gated the probe, 404 or 405 means no OPTIONS route exists, and 3xx means a redirect swallowed the probe. On the real request row, any status with a body but no Allow-Origin means the endpoint works and only permission headers are missing. The row the status sits on names the layer to fix.
Curl reproduces each side cleanly. An OPTIONS curl with Access-Control-Request-Method replays the probe from any terminal, which matters when the failing browser belongs to a customer. A plain GET curl with an Origin header replays the main request. Run both against the failing URL and you will know within a minute which half is broken, without screen-sharing.
Beware the hybrid that fools veterans: a preflight passes, then the real request fails its own check. The Network tab shows a green OPTIONS row followed by a red POST. That is a main-request failure wearing preflight clothes. Confirm by reading the POST's response headers for Allow-Origin before touching the OPTIONS config.
Framework Fixes Without Repeating the Basics
Every stack below does the same three things: answer OPTIONS with 204, emit the Allow headers, and run that logic before auth. What differs is where the code lives. In Express, mount a preflight handler before all other middleware, including auth and JSON parsing. The cors package does this when mounted first, but a hand-rolled five-line middleware works identically and keeps the ordering visible in your own code.
In Spring, declare the CORS mapping once in a WebMvcConfigurer with explicit origins, methods, and headers, and let the framework's built-in OPTIONS handling answer probes. Controller-level annotations work for single endpoints but scatter the config, so prefer the global mapping. Verify that Spring Security permits OPTIONS requests without authentication, since the security chain sits in front of MVC handling and can 401 probes before they reach it.
In Nginx, answer OPTIONS directly in the location block with a 204 and the Allow lines, then return before proxying upstream. Use the always flag on every add_header so error responses keep their CORS headers too. Without always, a healthy probe on 200 paths hides the fact that every 4xx probe leaves headerless, and your first rate-limit event becomes a fake CORS outage.
The ordering rule survives every framework: CORS before auth, OPTIONS before proxying, explicit lists instead of wildcards with credentials. Audit the chain after each dependency upgrade, since middleware registration order is exactly the kind of thing a major version quietly reshuffles.
Caching, Credentials, and the Last Three Gotchas
Three details cause the failures that survive a correct initial setup. First, preflight caching. Browsers cache successful probes for Access-Control-Max-Age seconds, so a fixed server can look broken until the old negative result expires. During development, disable cache in DevTools. In production, pick a Max-Age like 600 that balances chattiness against how fast you want config changes to propagate.
Second, credentials. The probe itself never carries cookies or Authorization, but the real request might, and the OPTIONS answer must already promise credential support with an echoed origin plus Allow-Credentials true. Teams often configure credentials on the main handler and forget the probe, so anonymous calls pass and logged-in calls fail on the identical endpoint. Configure both halves together.
Third, redirects. If the probed URL redirects, the browser applies the CORS check to the final response, and most servers do not emit Allow headers on redirect hops. Point cross-origin clients at final URLs and probe the exact URL the client calls, not its unredirected parent. A probe that passes on the bare domain and fails on the www subdomain is this bug wearing a costume.
Close the loop with a deploy-time probe. One scripted OPTIONS call per critical endpoint, asserting 204 plus the Allow headers, catches reorders, upgrade reshuffles, and missing headers before users do. It runs in under a second and has saved more launches than any dashboard.
Auth Middleware 401'd Every Preflight for 53 Minutes
- Middleware order is a security-relevant decision, so any reorder needs a preflight test. An unauthenticated OPTIONS probe returning 204 is a five-line test that would have blocked this deploy.
- Same-origin and curl tests never send preflights, which makes them blind to this whole failure class. At least one test must issue a real cross-origin JSON call.
- Mobile apps surviving while browsers die is the signature of a preflight bug. Teach on-call that split so triage starts at the middleware chain instead of the database.
| File | Command / Code | Purpose |
|---|---|---|
| preflight-triggers.js | async function jsonPost(url) { | What Triggers a Preflight |
| preflight-diagnose.js | async function diagnosePreflight(url, headers) { | Reading the OPTIONS Exchange in the Network Tab |
| options-server.js | const http = require('http'); | The Four Headers Your OPTIONS Answer Must Return |
| which-half-failed.js | async function whichHalfFailed(url) { | Preflight Failure vs Main-Request Failure |
| express-preflight.js | const express = require('express'); | Framework Fixes Without Repeating the Basics |
Key takeaways
Common mistakes to avoid
5 patternsProtecting OPTIONS with authentication
Returning 200 without Allow headers on OPTIONS
Debugging with plain curl and no Origin header
Adding a global custom header without updating Allow-Headers
Testing fixes with the preflight cache warm
Interview Questions on This Topic
Which requests trigger a CORS preflight and why?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Browser. Mark it forged?
6 min read · try the examples if you haven't