The CORS Mess at Twenty: Even the People Writing Explainers Are Fighting in the Comments

CORSWeb SecurityHTTPCross-OriginSame-Origin Policy

Sources:HN + Lobsters · HN

It’s 2 a.m. You’ve spun up Create React App locally, frontend on port 3000, backend API on port 8000. You write your first fetch() call, and Chrome’s Console spits out a red line: “has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.” You Google “CORS error fix.” The first Stack Overflow answer tells you “add Access-Control-Allow-Origin: * on the backend.” You do it. The red text vanishes. World peace. As for what that header actually does, why a server-side header controls browser-side behavior — you’re not entirely sure, and you’re not inclined to dig deeper. After all, the code runs now.

This is a scene playing out every second on Earth. Twenty years on, CORS remains the web development security mechanism most easily “fixed but not understood.” In June 2026, a 2019 article titled “Developers don’t understand CORS” resurfaced on Hacker News to 353 points and 251 comments. The two most-upvoted comments are directly opposed. The sub-threads beneath them battled through two hundred nested replies, both sides citing chapter and verse — and in the end, nobody convinced anybody. More tellingly, one of the objects of critique was the explainer article itself: “Even TFA (The F*ing Article) seemingly doesn’t understand CORS.” The people writing the explainers are also confused.

What CORS Actually Does

To understand this fight, you have to go back to the most basic question: what is CORS for? The picture I’ve assembled from technical literature and specifications goes roughly like this: CORS (Cross-Origin Resource Sharing) is a protocol implemented by browsers to relax, under specific conditions, the restrictions of the Same-Origin Policy (SOP). Yes — relax, not tighten. SOP is the security baseline built into browsers: by default, JavaScript loaded from example.com cannot send a request to bank.com and read the response. This default policy protects the user — if you’re logged into your bank, with authentication cookies in the browser, any other website you open cannot secretly read your bank’s data. CORS is a mechanism that lets a server say “certain other origins may read my responses,” via response headers like Access-Control-Allow-Origin.

The naming itself hints at its role: it’s about sharing, not blocking. But this naming is precisely the source of massive confusion — when developers see a Console error saying “blocked by CORS,” their instinctive reaction is “CORS is blocking me.” In reality, what’s blocking the request is SOP; CORS is the set of procedures the browser runs when you attempt a cross-origin request, checking whether the server has authorized it. If the server hasn’t authorized it, the browser’s behavior is “don’t let JavaScript read the response” (and, for non-simple requests, “don’t send the actual request at all”) — and this “don’t” gets attributed to the name “CORS.” The mismatch between name and behavior is the first installment of principal on the cognitive debt.

Two Kinds of “Right,” Fighting for Two Hundred Floors

The core disagreement in the HN discussion can be condensed into two opposing comments. The first, from user muvlon (top-voted, posted 17 hours earlier), runs roughly: the article itself doesn’t understand CORS — CORS doesn’t prevent requests; it only relaxes default restrictions. JavaScript from any website can send requests to your localhost:19421; the Access-Control-Allow-Origin header only determines whether the response can be read. The request itself goes out regardless. The second, from user stymaar (12 hours earlier), directly refutes: No, you’re wrong — for safe methods like GET, the request does go out, but GET is supposed to be idempotent; not being able to read the response is the entire protection. For non-idempotent requests, the browser first sends an OPTIONS preflight; if the preflight response lacks the correct CORS headers, the browser won’t send the actual request at all.

Neither person is talking nonsense. They’re each correct within the scenario they’ve defined. muvlon’s covered scenario is “simple requests” — those that don’t trigger a preflight: GET, HEAD, POST (with Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain), plus a set of safe standard headers. These requests go out, the server processes them, the response comes back — the browser just doesn’t hand the response to JavaScript. stymaar’s described scenario is “non-simple requests” — PUT, DELETE, PATCH, POST with Content-Type application/json, requests carrying an Authorization header, etc. These trigger an OPTIONS preflight first; if the preflight fails, the actual request never goes out.

The engineering judgment: both factions are right in their respective contexts, but each presented their rightness as universal truth. muvlon’s statement “the requests happen in any case” as a universal claim is wrong — for non-simple requests, a failed preflight genuinely prevents the request from being sent. stymaar’s use of the preflight mechanism to defend the original author also has gaps — he overlooked that the Zoom scenario involves a local localhost server, with an attack surface consisting of GET-class simple requests. The original author’s phrasing, “only JavaScript running on the zoom.us domain can talk to the localhost webserver,” is indeed imprecise: any website can “talk to” this localhost server (send simple requests); only authorized websites can read the response. If the localhost server exposes dangerous operations on GET endpoints, Access-Control-Allow-Origin can’t stop the request — only stop the response from being read. And a destructive GET request, once sent, is sent.

The Calculus of Preflight

The preflight mechanism itself hides more easily overlooked details. Someone in the HN discussion pointed out that a POST request with Content-Type set to text/plain can bypass preflight — because text/plain is on the “simple request” allowlist. An attacker can construct a form like this:

<form action="https://victim.com/api" method="POST" enctype="text/plain">
  <input name='{"key":"value", "ignore":"' value='"}'>
</form>

The content sent to the server becomes {"key":"value", "ignore":"="}, which looks like malformed JSON — but if the backend doesn’t strictly check the Content-Type header before calling JSON.parse on the body, this request can pierce the preflight barrier. A commenter claiming to have successfully exploited this technique in multiple penetration tests appeared in the thread. This isn’t pure theoretical speculation — as long as the server doesn’t do Content-Type validation, simple POSTs with text/plain or multipart/form-data can carry arbitrary payloads. Similarly, if Content-Type checking is done via prefix matching rather than exact matching, a header value like multipart/form-data; boundary=application/json can also bypass.

These edge cases illustrate something: CORS’s security model can’t be reduced to either “can the request reach the server” or “can the response be read.” It’s a branching tree — simple requests and non-simple requests take different paths, and the protection boundaries differ on each path. Generalizing the rules of any one path into universal rules produces cognitive distortion. And this branching tree keeps sprouting new limbs — Sec-Fetch-* headers, SameSite cookie attributes, Cross-Origin-Embedder-Policy, Cross-Origin-Opener-Policy — each layer stacks new semantics on top of CORS, making an already non-trivial mental model even harder to grasp.

Why Even the Explainer Authors Get It Wrong

Chris Foster’s original article, published in July 2019, centers on Zoom’s local webserver vulnerability. Zoom ran a webserver listening on localhost:19421 on users’ machines. When a user clicked a Zoom link, the webpage sent a request to this local server to launch the native client. To bypass CORS, Zoom didn’t use AJAX — it loaded an image and encoded the status code in the image’s dimensions. Foster’s recommendation: this local webserver should set Access-Control-Allow-Origin: https://zoom.us, so that “only JavaScript on zoom.us can communicate with the local server.”

Foster’s first-half judgment (Zoom’s approach is unsafe) is correct, but the second-half phrasing (“only zoom.us can communicate”) is technically ambiguous. Strictly speaking, Access-Control-Allow-Origin can’t prevent other websites from sending simple requests to localhost; it can only prevent other websites’ JavaScript from reading the response. If the local webserver exposes sensitive operations on GET endpoints, CORS headers alone aren’t enough.

But this isn’t Foster’s problem alone. The entire HN comment thread discusses CORS in various ways, and the disagreements among the discussants are no smaller than their disagreements with Foster. Some insist CORS can’t block any requests at all; others counter that preflight is precisely for blocking requests; a third jumps in to point out the POST text/plain and form request preflight bypass issue; a fourth adds that even if the request goes out, without CORS headers the response can’t be read, so protection for GET-class operations is complete — as long as the server doesn’t put write operations on GET endpoints. Each layer of rebuttal exposes the incompleteness of the previous layer. The end result: after 250 comments, there’s still no consensus.

I observe a pattern: CORS’s cognitive difficulty isn’t just because it’s complex — it’s also because it requires developers to simultaneously understand three things to build a correct model: the browser’s SOP baseline, CORS as SOP’s relaxation mechanism, and HTTP method safety and idempotency conventions. These three things belong to three separate domains — browser architecture, web security protocols, and RESTful design — and most developers are only familiar with one or two of them. When someone models with only “SOP + CORS,” they’re prone to concluding “the request was blocked” (because the overall effect at the browser level looks that way). When someone models with only “HTTP semantics,” they see that the server received the request and returned a response — “the request clearly went out.” Both models are correct at different levels, but they collide when projected onto the same noun: “CORS.”

The Generational Fault Line

One observation from the comments is particularly interesting: this might be a generational problem. If you started doing web development before CORS existed, you lived through an era with only SOP and no legitimate cross-origin requests. You know how JSONP was hacked together. You know why <img> tags and <script> tags could cross origins while XHR couldn’t. When CORS arrived, you saw SOP getting a door opened — it was a solution. But if you started writing web applications after CORS already existed, the first cross-origin error you encountered literally said “blocked by CORS.” Your instinct is that CORS is getting in your way — it’s a problem.

The generational difference is real, but the deeper issue is that CORS’s documentation, teaching, and error messages are designed with an embedded cognitive bias. The browser Console error message says “blocked by CORS,” not “blocked by Same-Origin Policy due to missing CORS authorization.” MDN documentation explains the full mechanism, but most developers don’t read the full documentation — they search for the first Stack Overflow answer that fixes the problem and stop. More than one person in the HN comments admitted: “Every time I encounter a CORS problem, I have to relearn it, then forget it again.” A self-described CTO commented that users at their company frequently encounter CORS problems and seek support, and their observation was: you don’t need to truly understand it anymore, because Claude and GPT can now fix CORS errors — just toss the error at an LLM. Another immediately rebutted: a recent CORS error they encountered pierced through Claude, Copilot, and a senior engineer’s three lines of defense before being resolved. If even the people writing explainers and reading explainers are fighting each other, how reliable can the answers LLMs have learned from chaotic training data possibly be?

A Debt That Won’t Be Settled

CORS’s designers faced a nearly impossible task: provide a secure authorization mechanism for cross-origin browser interactions while maintaining compatibility with twenty years of web history. HTML <form> cross-origin capability had existed for over two decades before CORS appeared; simply shutting it down would break the entire internet. CORS chose a compromise path: keep “simple requests” backward-compatible, introduce preflight for “non-simple requests.” This choice was pragmatic at the time, but it internalized complexity into the protocol itself — developers must understand which requests are simple and which aren’t, which headers are safe and which aren’t, why OPTIONS requests appear and what their relationship is to the actual request. Twenty years later, layered with SameSite, Sec-Fetch, COEP, COOP, and more, the complexity has only grown.

I’m inclined to think CORS’s cognitive debt is rooted in the web platform’s own evolutionary pattern — backward compatibility is a hard constraint, phased evolution is the only viable path, and the compromises of each phase leave conceptual debt that subsequent developers must pay extra to learn. This debt is hard to settle because it’s already etched into the DNA of browsers and billions of web pages.

That HN comment thread probably won’t become the endpoint of the CORS problem. But it’s a decent cross-section — it shows that even the people in the technical community who care most about this topic, gathered together in intense discussion for two days, still can’t agree on the most basic facts. If even this group can’t unify, expecting ordinary developers to precisely master every detail of CORS may be unrealistic.


This article is based on technical analysis of Chris Foster’s original post and the Hacker News discussion thread. The author is not an original author of the CORS specification nor a browser engine developer; interpretations of technical mechanisms herein are drawn from reading and understanding public standards documents and community discussions, and may contain inaccuracies. If you find technical errors in this article, please defer to the WHATWG Fetch Standard and MDN Web Docs.