Every CORS debugging session eventually arrives at the OPTIONS request. You send a POST from your frontend, the browser intercepts it, sends an OPTIONS request to your server first, and your server — if you haven't thought about this — returns a 404 or a 405 and the whole thing fails before your actual request even leaves the browser. It feels arbitrary. It is not.
What the same-origin policy was protecting
In 1999, Netscape implemented the same-origin policy: JavaScript on one origin (scheme + host + port) cannot read responses from another origin. This was a security boundary. Without it, a malicious page could silently fetch your bank's API using your authenticated session cookies and read the response.
The policy had a well-known exception: you could always send cross-origin requests via forms, you just couldn't read the response. This was by design — HTML forms predate the same-origin policy, and retroactively blocking form submissions to other origins would have broken the entire web. The concession was: sending is fine, reading is gated.
This created a gap. When XMLHttpRequest was introduced and could make requests programmatically, it initially inherited the same-origin policy: no cross-origin requests at all. For a while this was the rule. But the web needed cross-origin requests to work. APIs needed to be called from browsers. CDNs needed to serve JavaScript from different domains than the main application. The restriction was too strict.
Why simple requests are different
CORS, introduced in the mid-2000s and finalized in standards around 2014, solved this by distinguishing two categories.
Simple requests are GET, POST, and HEAD requests with a limited set of headers (basically: what a normal HTML form can send). These are sent directly, without a preflight. The same-origin policy reasoning here is: these requests are already possible via form submission. HTML forms have been able to POST to any origin since 1991. If a server is vulnerable to a POST from a browser, that was true before CORS existed. Allowing the response to be read by JavaScript is what's new, and that's controlled by the Access-Control-Allow-Origin response header.
Non-simple requests are everything else: PUT, PATCH, DELETE, requests with custom headers like Authorization or Content-Type: application/json. These are not things HTML forms could ever send. A browser-side script using these methods to attack a server is a new capability that didn't exist in 1999. The preflight OPTIONS request exists to check, before sending the real request, whether the server actually intends to handle this kind of cross-origin call.
This is the key insight: the preflight is asking "did you opt in to receiving this kind of request?" A server that doesn't know about CORS won't respond to the OPTIONS preflight with the right headers, so the browser will block the real request. This prevents a naive pre-CORS server from being exploited by a new kind of browser request it never expected to receive.
The practical implication
If you control both the frontend and the API, the preflight is mostly overhead you have to handle. The canonical setup:
# Every OPTIONS request to your API must return 200 with these headers:
Access-Control-Allow-Origin: https://your-frontend.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 # cache this for 24h
The Access-Control-Max-Age header is worth setting. Without it, the browser sends a preflight before every non-simple request. With it set to 86400 (24 hours), the browser caches the preflight result and skips the OPTIONS call for subsequent requests to the same endpoint. This can meaningfully reduce API latency for applications making many cross-origin requests.
What actually bites you
Returning 404 on OPTIONS is the most common mistake. Your router doesn't have an OPTIONS route, returns 404, and CORS fails. The fix is either a catch-all OPTIONS handler or framework-level CORS middleware.
Reflecting the request origin back verbatim. Some implementations return Access-Control-Allow-Origin: * for unauthenticated endpoints and reflect the origin for authenticated ones. The bug is reflecting the origin without validating it against an allowlist. Fix: maintain an explicit list of allowed origins; only reflect if the request origin is in the list.
Credentials + wildcard origin doesn't work by spec. If your request includes cookies or an Authorization header (withCredentials: true), the browser requires an explicit origin in the response header, not *. A wildcard with credentials is rejected. Set the specific allowed origin.
The error message is in the browser console, not the server log. CORS failures are enforced by the browser, not the server. The server may return a 200; the browser blocks the response from being read. Your server logs will look fine while your frontend fails. Look at the browser network tab and console.
The broader lesson
CORS is confusing because it's a backward-compatibility mechanism layered on top of a security model built on top of a web that predates JavaScript. The preflight exists specifically because the web can't break old servers by sending them new kinds of requests they weren't expecting. The complexity is not accidental — it's the shape of thirty years of trying to add security to a platform that was designed without it.
Once you see the preflight as a consent check rather than bureaucracy, the rules start making sense. The browser is asking: "Do you know about CORS? Did you mean to accept this kind of request?" Your job as an API developer is to answer yes, explicitly, and to mean it.
---
Find more writing at anethoth.com. If you're building an indie SaaS, list it on builds.anethoth.com.