Skip to content

CORS, the same-origin policy, and the long history of cross-origin trust

· 21 min read
Copyright: MIT
CORS and SOP wordmark over a dark background with an orange underline bar

Open the developer console on almost any modern web app, do something that hits an API on a different host, and sooner or later you will see it: a red line complaining that a response was blocked by CORS policy, that the Access-Control-Allow-Origin header is missing or does not match. It is the single most-cursed error message on the web platform. Engineers who have shipped for a decade still reach for Stack Overflow when it shows up, slap a wildcard on the server, and move on. That reaction is exactly backwards. The error is not the bug. The error is the browser doing its job.

To understand why the browser is right to be paranoid, you have to go back to the thing CORS exists to selectively undo: the same-origin policy. The SOP is the oldest load-bearing security boundary on the web, it predates almost every spec you have read, and it was never written down in one place until fifteen years after it shipped. CORS is the controlled leak in that wall. This post walks the whole arc. What the SOP actually protects and what it conspicuously does not. How an origin came to be formally defined. The exact mechanics of a preflight and every Access-Control-* header. The misconfigurations that turn a relaxation into a hole. And where the boundary sits in 2026, after document.domain went away and private-network requests grew their own preflight.

What the same-origin policy actually is

The same-origin policy arrived with JavaScript. Netscape shipped scripting in Navigator 2.0 in 1995, and almost immediately the question became unavoidable: if a script on one page can read the document, what stops a script on an attacker’s page from reading a document belonging to your bank? The answer, baked into Navigator 2.02 in 1995 and patched through 2.01 and 2.02 as the coverage gaps showed up, was the same-origin policy. A script may read and manipulate a document only if the script’s origin and the document’s origin are the same.

For two decades the policy had no canonical specification. It lived in browser source code and in the shared, slightly-different intuitions of the people who maintained that code. Each engine drew the boundary in roughly the same place, with enough disagreement at the edges to keep security researchers employed. That ended in December 2011, when the IETF published RFC 6454, The Web Origin Concept, authored by Adam Barth at Google. RFC 6454 finally wrote down what an origin is and how to compute it, and it defined the Origin HTTP header that the whole CORS machine would later depend on.

An origin is a triple: scheme, host, and port. Two URLs share an origin only when all three match exactly. https://app.example.com and https://api.example.com are different origins because the host differs. https://example.com and http://example.com are different because the scheme differs. https://example.com and https://example.com:8443 are different because the port differs, even though browsers hide the default 443 and the two look identical in the address bar. This is a stricter rule than most people carry in their heads, and the strictness is the point.

An origin is the triple { scheme, host, port } all three must match exactly for two URLs to be same-origin https://app.example.com:443/dashboard scheme host port https://app.example.com vs https://api.example.com cross-origin https://example.com vs http://example.com cross-origin https://example.com vs https://example.com:8443 cross-origin *Scheme, host, and port each independently break origin equality. The address bar hides the default port, which is why the third example surprises people.*

Note what the SOP does not cover. It draws the boundary at the host, not the registrable domain, so the policy by itself treats subdomains as separate origins. It also distinguishes resources by how they were loaded. A document fetched into a frame carries the authority of its origin. An image, a stylesheet, or a script pulled in by a tag does not get that treatment in the same way, which is the loophole the next section depends on.

The boundary the policy never drew

Here is the part that trips up even experienced engineers. The same-origin policy does not stop your browser from sending a cross-origin request. It stops your script from reading the response.

When a page at evil.example runs fetch('https://bank.example/account'), the browser does send that request. If you are logged into the bank, the browser even attaches your cookies, because cookies are governed by their own rules and were grandfathered in long before anyone worried about cross-site reads. The request lands at the bank, the bank processes it, and a response comes back. What the SOP does is refuse to hand that response body to the script on evil.example. The data made the round trip; the attacker’s JavaScript just never gets to see it.

That distinction is the entire foundation of cross-site request forgery. A cross-origin POST that changes state, a transfer or a password reset, succeeds at the server even though the attacker can never read the reply, because the side effect already happened. CSRF defenses exist precisely because the SOP blocks reads but not writes. The flip side is what CORS governs: when a legitimate cross-origin app genuinely needs to read a response, something has to grant that permission without reopening the door for evil.example. That something is a set of response headers the server controls.

Several escape hatches predate CORS, and they are worth knowing because they explain the shape of what replaced them. document.domain let two pages on sibling subdomains agree to be treated as same-origin by both setting the property to a shared parent value, a trick that landed in Navigator 3 in 1996. JSONP abused the script-tag loophole, wrapping JSON in a function call so a <script src> could pull cross-origin data and run it, with the obvious cost that you were executing whatever the remote host sent. window.postMessage gave frames a structured, origin-checked channel to pass strings without sharing a DOM. Each of these solved a real problem and carried a real hazard, and CORS was the attempt to do the same job with the server, not the script, holding the keys.

How CORS relaxes the wall

CORS is a negotiation. The browser advertises the requesting origin, the server decides whether to permit a read, and the browser enforces that decision. The whole protocol now lives inside the WHATWG Fetch Standard, which absorbed it after the older standalone specification fell out of step with what browsers actually did. More on that history below.

The protocol splits requests into two classes. Some cross-origin requests are sent straight away, with the server’s response headers consulted only when the reply arrives. Others are gated behind a preliminary check, the preflight, before the real request leaves the browser. The split exists to preserve a guarantee the web already made before CORS existed: that any request a plain HTML form could already send cross-site stays sendable without a new permission step, because blocking those would break a billion existing pages, while anything a form could not send, anything that smells like a programmatic API call, gets the gate.

A request skips the preflight and goes out immediately only when it stays inside the envelope of what a form or an image tag could already do. The method has to be GET, HEAD, or POST. The headers have to be confined to the CORS-safelisted set, which per the Fetch Standard is Accept, Accept-Language, Content-Language, Content-Type, and Range, each with value constraints. And if Content-Type is present it has to be one of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Set a Content-Type: application/json, or add an Authorization header, or use PUT, and you have left the envelope. The request now triggers a preflight.

Does a cross-origin request need a preflight? method in GET / HEAD / POST ? only safelisted headers ? Content-Type in the 3 form encodings ? no any "no" sends an OPTIONS preflight first all three "yes" = simple request, sent immediately *The simple-request envelope mirrors what an HTML form could already send cross-site. Step outside it and the browser asks permission first.*

For a simple request, the browser attaches an Origin header naming the requesting origin and sends it. When the response comes back, the browser looks for Access-Control-Allow-Origin. If that header echoes the requesting origin or contains the wildcard *, the browser releases the response body to the script. If it is missing or names some other origin, the browser keeps the body and logs the error everyone knows. The request still happened. The bank still saw it. The script just gets nothing.

The preflight, header by header

When a request falls outside the simple envelope, the browser does not send it. It first sends an OPTIONS request to the same URL, carrying Origin, an Access-Control-Request-Method naming the method the real request will use, and, if there are non-safelisted headers, an Access-Control-Request-Headers listing them. This is the browser asking the server, in advance, whether the real request would be allowed. No request body, no side effects. Just a question.

Browser (foo.example) Server (api.bar.example) OPTIONS /data Origin / Access-Control-Request-Method: PUT Access-Control-Request-Headers: authorization 204 No Content Access-Control-Allow-Origin: https://foo.example Access-Control-Allow-Methods: PUT, GET, POST Access-Control-Allow-Headers: authorization Access-Control-Max-Age: 86400 PUT /data (the real request, now permitted) 200 OK + Access-Control-Allow-Origin *The preflight is a yes/no question asked before any side effect. A cached preflight (Access-Control-Max-Age) skips the OPTIONS round trip for subsequent calls.*

The server answers the preflight with its own set of headers. Access-Control-Allow-Origin names the permitted origin. Access-Control-Allow-Methods lists the HTTP methods it will accept. Access-Control-Allow-Headers lists the request headers it will accept, and this list has to include whatever the browser asked about in Access-Control-Request-Headers or the preflight fails. Access-Control-Max-Age tells the browser how many seconds it may cache this preflight result, so a chatty client does not pay for an OPTIONS round trip before every single call. Only when the preflight passes does the browser send the real request, and the real response is then checked against Access-Control-Allow-Origin exactly as a simple response would be.

Two more response headers round out the set. Access-Control-Expose-Headers controls which response headers a cross-origin script is allowed to read; by default a script can read only a short safelist of response headers, and anything else, a custom X-Request-Id or a pagination header, stays invisible until the server explicitly exposes it. And Access-Control-Allow-Credentials is the one that changes the security calculus entirely.

Credentials change everything

By default, fetch does not send cookies or HTTP auth on a cross-origin request. A script that wants them has to opt in, with credentials: 'include' on the Fetch API or withCredentials = true on XMLHttpRequest. When it does, the server has to opt in too, by returning Access-Control-Allow-Credentials: true. Without that header on the response, the browser discards the response even if the origin matched. Credentialed cross-origin reads are off unless both sides agree.

There is a hard rule that the wildcard and credentials cannot coexist. If a request is credentialed, Access-Control-Allow-Origin: * is not accepted by the browser; the server must echo back a specific origin. The same applies to Access-Control-Allow-Methods and Access-Control-Allow-Headers, where the wildcard is ignored for credentialed requests and an explicit list is required. The reasoning is direct. A wildcard means “any origin may read this,” and “any origin may read this with the victim’s cookies attached” is never something a server actually wants to say. The spec makes that combination unstateable. The trouble starts when a developer, wanting both a broad allowlist and credentials, writes code to dynamically satisfy the rule by reflecting whatever origin showed up. That is where CORS stops protecting anyone.

Where CORS goes wrong

The failure modes are not exotic. They are what happens when an engineer treats the CORS error as an obstacle to silence rather than a check to satisfy correctly. The relationship between the SOP and credentials is the same one at the heart of SSRF and the cloud-metadata attack that breached Capital One: an over-trusted boundary, plus ambient credentials, equals data exfiltration. CORS just moves the boundary into the browser.

The first and most common mistake is origin reflection. The developer wants the API reachable from several origins, can’t use a wildcard because the API needs credentials, and so writes the server to read the incoming Origin header and copy it straight into Access-Control-Allow-Origin, paired with Access-Control-Allow-Credentials: true. This passes every test the developer runs, because their own origin is always reflected. It also reflects evil.example. Any page on the internet can now make credentialed requests to the API as the logged-in victim and read the responses. The reflection turned the allowlist into “everyone,” with credentials, which is the one combination the wildcard rule was written to forbid. The developer reimplemented the forbidden thing by hand.

The second is sloppy allowlist matching. A team that genuinely wants to allow its own subdomains writes a check, and the check is a substring or a poorly anchored regular expression. A test for whether the origin “ends with example.com” passes for evilexample.com, which an attacker can simply register. A test for whether it “contains example.com” passes for example.com.evil.net. A test that allows any subdomain trusts every subdomain, including the one running unpatched user-generated content or the forgotten staging box that an attacker can XSS. The allowlist was meant to name a few origins and instead named a family of attacker-controllable ones.

Allowlist checks that look right and aren't origin.endsWith("example.com") evilexample.com origin.includes("example.com") example.com.evil.net /.*\.example\.com/ (unanchored) a.example.com.evil.net any *.example.com xss-able-staging.example.com Right answer: exact-match against a fixed set of strings. Nothing clever. *Every left-hand check passes for the right-hand attacker-controlled origin. Origin matching is string equality against a known set, not pattern matching.*

The third involves the null origin. The browser sends Origin: null in several situations, including requests from a sandboxed iframe and requests from non-hierarchical schemes like data:. During local development it is tempting to add null to the allowlist so that file-based testing works, and the temptation survives into production more often than anyone would like. The problem is that an attacker can produce a null origin on demand. A page that embeds a sandboxed iframe with sandbox="allow-scripts" runs attacker JavaScript in a context whose origin serializes to null. If the server trusts null and allows credentials, that sandboxed frame becomes a credentialed reader of the victim’s data. null is not a safe sentinel. It is an origin an attacker can mint.

A subtler failure breaks TLS without touching TLS. Suppose the main app is https://app.example.com and its CORS allowlist trusts http://staging.example.com, plain HTTP, for some legacy reason. An attacker positioned on the network can tamper with the unencrypted staging traffic, inject a response that redirects or scripts the victim’s browser into making requests, and ride the CORS trust back to the HTTPS origin. The HTTPS app’s careful certificate validation bought nothing, because it extended trust to an origin that has no encryption at all. An allowlist is only as trustworthy as the least-protected origin on it.

The reason these bugs are worth this much attention is that a CORS hole is a read primitive against authenticated data. It is in the same family as the cookie-handling mistakes covered in HttpOnly, Secure, SameSite, and the __Host- prefix, and it composes with them: SameSite cookie attributes and CORS are two different walls around the same valuables, and an app needs both standing. A correct CORS configuration is boring. It exact-matches the incoming origin against a small hardcoded set, returns that specific origin or nothing, never reflects, never wildcards when credentials are in play, and never trusts null. The boredom is the security.

CORS is not a server-side firewall

A recurring misconception deserves its own paragraph, because it sends people in exactly the wrong direction. CORS does not protect your server. It protects your users’ browsers. The enforcement happens entirely in the browser, after the response has already been computed and sent. A server that returns Access-Control-Allow-Origin: https://trusted.example has not stopped anyone from requesting the resource; it has stopped browsers running untrusted JavaScript from reading the reply. Any HTTP client that is not a browser, curl, a Python script, a backend service, ignores Access-Control-* headers completely, because those headers are instructions to the browser’s enforcement layer and nothing else cares.

This means two things that engineers conflate. Tightening CORS does not add access control to an API; an unauthenticated endpoint with strict CORS is still unauthenticated and still readable by any non-browser client. And loosening CORS does not expose a server to direct attack from scripts that were not going to run in a victim’s browser anyway. The header is a same-origin-policy override switch, scoped to the browser, and reasoning about it as a network-layer control leads to both false comfort and misplaced fear. If the resource needs protecting from arbitrary clients, that protection is authentication and authorization on the server, the same as it always was. CORS sits a layer above, deciding which web origins the browser will let read what the server already decided to return.

The standards history, and why the spec moved

The path from idea to living standard is a small lesson in how web specs actually mature. Cross-origin sharing began as a narrow need. In March 2004, Matt Oshry, Brad Porter, and Michael Bodell of Tellme Networks proposed a mechanism for VoiceXML 2.1 to make safe cross-origin data requests. The idea was obviously more general than voice apps, so it was lifted out into its own work item. The W3C WebApps Working Group, with the browser vendors at the table, turned it into a Working Draft, the first of which landed in May 2006 under the cumbersome name it would not keep.

From an unwritten rule to a living standard 1995 SOP in Navigator 2004 Tellme VoiceXML 2006 first W3C draft 2009 named "CORS" 2011 RFC 6454 origin 2014 W3C Rec 2017 moved to Fetch *The same-origin policy shipped twenty years before it had a canonical spec. CORS took thirteen years to go from a VoiceXML side-note to a W3C Recommendation, then left for the Fetch Standard three years later.*

In March 2009 the draft was renamed Cross-Origin Resource Sharing, the name it kept. It reached W3C Recommendation status in January 2014, which would normally be the end of the story. It was not. By August 2017 the W3C Recommendation had drifted from what browsers actually shipped, and rather than keep two documents in uneasy parallel, the W3C marked its version obsolete and pointed implementers at the WHATWG Fetch Standard, where CORS now lives as one integrated piece of the larger fetching algorithm. The move matters for anyone citing the spec in 2026: the authoritative text is the Fetch Standard, not the 2014 W3C Recommendation, and the older document carries a banner saying so. The same WHATWG-versus-W3C realignment played out across HTML and the DOM in the same era; CORS was one casualty of a broader decision about where the web’s living specifications should be maintained.

What changed in the modern era

The boundary has not stood still. Two changes since 2022 are worth knowing because they affect how the origin model behaves today.

The first is the death of document.domain. The old subdomain-merging trick was a security liability: two pages setting document.domain to a shared parent could treat each other as same-origin, which on shared hosting meant one tenant could potentially reach into another’s frame. Chrome moved to origin-keyed agent clusters by default and disabled the document.domain setter starting in Chrome 115, in mid-2023. A page that genuinely still needs the old behavior has to opt back in by sending the Origin-Agent-Cluster: ?0 response header, and every cooperating document has to send it too. The default flipped from “subdomains can merge if they ask” to “subdomains stay separate unless everyone opts out,” which is the safer default and the one that should have been there all along.

The second is Private Network Access, the effort to stop a public website from quietly reaching into the user’s private network, a printer’s admin page, a router at 192.168.1.1, a service bound to localhost. The mechanism reuses the CORS preflight idea with a new pair of headers. When a public page tries to fetch a private-network resource, Chrome sends a preflight carrying Access-Control-Request-Private-Network: true, and the target has to answer with Access-Control-Allow-Private-Network: true to permit the request. This is broader than ordinary CORS preflighting: it fires for all private-network subresource requests regardless of method or mode, including no-cors, precisely because any such request can be turned into a CSRF against an internal device. The rollout has been slow and the naming has churned, from CORS-RFC1918 to Private Network Access and on toward a permission-prompt model for local network access, with deprecation trials extending the timeline well into 2025. The shape, though, is settled: the same preflight handshake the web already understood, aimed at a boundary the original SOP never thought to draw, because in 1995 nobody was loading a public web page that could talk to the thing on your desk.

Both changes share a direction. The web is tightening origin isolation, not loosening it, walking back the relaxations of the early 2000s now that the security cost of those relaxations is fully understood. document.domain was a 1996 convenience that became a 2023 liability. The private-network boundary is a 2020s addition to a model that assumed, in 1995, that everything interesting lived on the public internet.

Closing: the wall and the door

The same-origin policy is the web’s oldest security boundary and one of its least understood, because it does the counterintuitive thing of blocking reads while allowing writes, and almost every confusion about web security downstream of it, CSRF, CORS holes, cookie scoping, traces back to people forgetting that asymmetry. CORS is the door cut into that wall, and like any door its security depends entirely on who holds the key and how carefully they check who is knocking. The browser holds the enforcement. The server holds the policy. When the policy says “reflect whatever origin asks, and send credentials,” the door is propped open and the wall is decorative.

What is striking about the 2026 state of play is how much of it is correction. The origin model spent twenty years undocumented, then got an RFC. CORS spent a decade migrating from a side-note to a Recommendation to an obsoleted Recommendation to a section of the Fetch Standard. document.domain went from feature to footgun to disabled-by-default. Private Network Access bolted a new preflight onto a boundary the original designers never imagined. The wall did not get torn down and rebuilt. It got patched, argued over, formalized, and quietly hardened, one default at a time, by people who learned the hard way exactly what the gaps allowed. The red CORS error in your console is the visible edge of all of that. It is worth reading it as a question the browser is asking on a user’s behalf, and answering it precisely, instead of reaching for the wildcard.


Sources & further reading

Further reading