CORS Was Never a Security Boundary — Everyone Just Started Treating It Like One
Cross-origin resource sharing doesn't add a restriction. It removes one — and the header that removes it tends to get written exactly once, by whoever needed the frontend to stop breaking that week.
The Same-Origin Policy is the browser default CORS exists to relax, so it's worth being exact about what that default actually does before talking about the mechanism that loosens it. By default, a script running on origin-a.example cannot read the response of a request made to origin-b.example — it can often still send the request (a form submission, an <img> tag, in many cases a fetch with default settings), but the browser withholds the response body from the requesting script. That's the boundary: not "can origin A talk to origin B," but "can a script on origin A read what origin B sent back." Same-Origin Policy was never trying to stop cross-origin requests from happening. It was trying to stop a script from silently exfiltrating the content of a response the user's browser received from somewhere the script shouldn't be able to read — your logged-in email inbox, your bank balance, anything a script on an unrelated page had no business seeing.
CORS is the mechanism by which the destination server opts back into being readable cross-origin. An Access-Control-Allow-Origin header on the response tells the browser "yes, let the origin named here read this" — and the browser, which is the only party actually enforcing Same-Origin Policy in the first place, honors that and hands the response back to the requesting script. That's the entire mechanism: a server-issued permission slip, checked and enforced client-side, that widens a default the browser would otherwise apply.
CORS is not, and was never designed to be, a mechanism for restricting who can access a server's resources. It is a mechanism for a server to tell browsers which origins are allowed to read responses that the server was already willing to send. A server that actually needs to restrict access — to specific users, specific clients, specific origins in a way that matters for security rather than for browser script readability — needs authentication and authorization doing that work. CORS headers control none of that; a request from curl, from a mobile app, or from a script running server-side never goes near a CORS check at all, because CORS is enforced by browsers, not by the server being called.
That last point is the one worth sitting with rather than re-litigating, because it's already well understood and doesn't need re-proving here: a permissive or even wildcard CORS policy on an endpoint that has no sensitive, session-dependent response doesn't create a vulnerability, because there was nothing to protect from a browser-script reader that a non-browser client couldn't already reach directly. Where CORS misconfiguration does become a real vulnerability is narrower and specific — an endpoint that returns something session-scoped or sensitive, combined with a policy that reflects the requesting Origin header back verbatim (Access-Control-Allow-Origin: <whatever Origin you sent>) instead of checking it against an actual allowlist, especially when paired with Access-Control-Allow-Credentials: true. That combination tells the browser "read this authenticated response, from any origin that asks" — which is precisely the readable-cross-origin-response problem Same-Origin Policy existed to prevent, reintroduced deliberately by a server that meant to be permissive and ended up being unconditionally permissive instead.
Simple Requests, Preflights, and Why Credentials Change Everything
Not every cross-origin request is subject to the same amount of browser scrutiny, and the distinction matters for understanding what CORS is actually checking and when. A "simple request" — a GET, HEAD, or POST using only a small set of allowed headers and content types the browser considers safe defaults — goes out immediately, exactly as it would same-origin; the browser only withholds the response from the calling script afterward if the CORS headers don't permit it. Anything outside that narrow definition — a PUT or DELETE, a custom header like Authorization or X-CSRF-Token, a Content-Type of application/json — triggers a "preflight": the browser sends an OPTIONS request first, asking the server to declare, via Access-Control-Allow-Methods and Access-Control-Allow-Headers, whether the actual request it's about to make would be permitted. Only if the preflight response says yes does the browser send the real request at all. This is why adding a custom header to an API call is a common way to accidentally discover that CORS was never configured for anything beyond simple requests — the preflight fails, and the real request never leaves the browser.
Credentials add a second, separate axis to all of this. A cross-origin request that should carry cookies or HTTP auth headers requires the client to explicitly opt in (credentials: 'include' in a fetch call) and requires the server to respond with Access-Control-Allow-Credentials: true — and critically, the CORS specification refuses to let a server combine that with a wildcard Access-Control-Allow-Origin: *. A server that wants to allow credentialed cross-origin requests has to name specific origins, one at a time or checked against an allowlist, precisely because "any origin may read authenticated responses" is the exact combination that turns a permissive policy into a real vulnerability rather than a merely sloppy one. A server that reflects the incoming Origin header back as the allowed origin, on every request, technically satisfies that "name a specific origin" requirement on paper while defeating its purpose entirely — which is the single most common way real CORS misconfigurations actually get found.
What Access-Control-Allow-Origin: * Actually Promises, and What It Doesn't
Wildcard origins deserve their own scrutiny separately from reflected origins, because the two get conflated constantly and they fail in different ways for different reasons. Access-Control-Allow-Origin: * is not, by itself, unsafe — it's an honest declaration that the response contains nothing origin-sensitive, and for a genuinely public API (a weather feed, a public documentation search index, a CDN-hosted static asset) that declaration is simply true. The specification's refusal to allow * alongside Access-Control-Allow-Credentials: true exists precisely to prevent the one combination where the wildcard's honesty would be violated — "any origin can read this" plus "and also send the user's cookies while doing it" together describe exactly the readable-authenticated-response-from-anywhere scenario Same-Origin Policy exists to prevent.
Where this gets misread is in assuming that avoiding the literal * character is the same as avoiding the underlying problem. A server that checks the incoming Origin header against a list and, finding no match, falls back to reflecting whatever was sent anyway — a defensive-seeming "let's not break anything" default written by someone who didn't want a legitimate but unlisted origin to get blocked — reproduces the wildcard's actual permissiveness while looking, to a quick code review, like a properly scoped allowlist. The tell is almost always in how the failure path of the origin-matching logic behaves, not in whether a literal asterisk appears anywhere in the code, which is exactly the kind of distinction that's easy to miss in a fast review and easy to find once you already know to look for it.
The Header Nobody Revisits
The organizational shape here is narrower than SSRF's and doesn't need much dressing up: a CORS policy almost always gets written once, at the moment some frontend — a new web app on its own subdomain, a separate marketing site, a partner's application — needs to call an API and gets blocked by the browser's default. Whoever is on the hook for that integration adds the header. The fastest version of "add the header" that makes the error go away is broad: allow the specific origin that's currently failing, or, under more time pressure, allow anything. Either way, the policy gets set to solve today's integration, by someone whose actual goal is "make this fetch call work," not "define this API's cross-origin trust model for the next several years."
A broad or reflected-origin CORS policy unblocks whatever integration prompted it immediately, with no coordination required across teams and no need to maintain an explicit list of every legitimate calling origin as they change.
The policy accumulates permissiveness in one direction only. Every new origin that needs access gets added or waved through; origins that no longer need access are essentially never removed, because removing access isn't a fire anyone is putting out. The policy that shipped to unblock one integration becomes, unaudited, the policy governing every integration added afterward.
Nobody owns coming back to that header later. It isn't in most teams' definition of "done" for a feature to schedule a review of the CORS policy it just touched; it isn't typically anyone's job description to periodically audit which origins an API allows and whether each still needs to. The header sits in a config file or a middleware chain, correct for the integration that motivated it, and stays there — through new frontends, new subdomains, new third-party partners, acquisitions that bring in new origins that inherit the old policy — until either a security review happens to catch it or an incident forces the question. That's the pattern this series keeps finding: not a mistake made once, but a decision made once, under real time pressure, in a context where making it narrowly correct for that moment was a reasonable use of the time available, and then never assigned to anyone as something to keep correct as everything around it changed.
The Part of This That Isn't New, and the Part That's Easy to Miss Anyway
It's well established, at this point, that CORS misconfiguration gets miscategorized as "a security hole" when what actually failed was authentication or authorization on the server itself — that critique has been made often enough that restating it at length here would be retreading ground the security-writing genre this series is explicitly trying not to repeat. What's less often said clearly, because it's less quotable, is the practical corollary: fixing a CORS policy never fixes an authorization gap underneath it. A team that discovers a reflected-origin CORS misconfiguration, tightens the policy to an explicit allowlist, and considers the finding resolved has fixed the browser-readability problem — real, worth fixing — without necessarily asking the separate question of whether the endpoint the policy was protecting should have required stronger authentication in the first place, independent of which origins can read its responses. The two fixes get bundled together often enough, in incident writeups and in ticket titles, that it's worth keeping them conceptually apart: CORS controls who can read a response through a browser. It has never controlled who can get one.
The trust boundary here is the one between "which origins were allowed to read this response when the policy was written" and "which origins should be allowed to read it now" — and for most APIs, the honest answer to who's kept those in sync is nobody, because the header shipped attached to a feature, not to a role.