Security Engineering · Web Application Security
How a Browser Computes an Origin, and Why Ports and Schemes Count
The tuple comparison behind the same-origin policy, the opaque origins that never match anything, and the document.domain exceptions that weaken the rule.
Every access-control decision a browser makes about a document reduces to a comparison of origins. Whether a script may read a response body, whether a frame may reach into its parent’s DOM, whether a stored credential is attached to a request — all of these consult the same equality predicate. The predicate is stricter than most developers expect, and the exceptions are narrower than folklore suggests.
RFC 6454 defines the concept, and the HTML and Fetch standards give it operational form. An origin is a tuple of scheme, host, and port. Two origins are the same if and only if all three components are identical. There is no notion of “same site” here, no notion of a trusted sibling, and no partial credit for matching two of the three.
The tuple and its normalisations
The scheme component is compared as a lowercase string after the URL parser has resolved it. HTTPS and https are the same scheme; the parser lowercases the scheme before the origin exists, so the comparison never sees mixed case.
The host component is where most of the subtlety lives. The URL parser applies the host parsing algorithm, which for special schemes — http, https, ws, wss, ftp, file — uses the domain-to-ASCII algorithm. That algorithm lowercases, applies IDNA mapping, and runs the Punycode conversion for non-ASCII labels. The consequence is that https://exämple.com and https://xn--exmple-cua.com produce the same host, because the parser converts the first into the second. A comparison performed on the raw input string rather than the parsed host would treat them as different, which is the wrong direction: it would be too permissive.
The domain-to-ASCII algorithm also handles trailing dots. example.com. is a fully qualified name and the parser strips the final empty label, so it yields example.com. A string comparison of the two would fail; the parsed comparison succeeds.
IPv4 addresses are canonicalised aggressively. The host parser accepts a variety of legacy forms — a single integer, dotted octal, dotted hex — and normalises them all to dotted-decimal. http://2130706433/, http://0x7f.1/, and http://127.0.0.1/ are the same host after parsing. This matters for any allowlist that compares strings: an allowlist entry for 127.0.0.1 does not catch the integer form unless the comparison is done on parsed hosts. The same reasoning applies to IPv6, where the parser compresses and lowercases, so http://[::1]/ and http://[0:0:0:0:0:0:0:1]/ are equal.
The port component is the effective port, not the textual one. A URL with no explicit port uses the default for its scheme: 80 for http, 443 for https. Because the default is filled in, https://example.com/ and https://example.com:443/ are the same origin. https://example.com:8443/ is a different origin from both, and that difference is enforced: a page on the default port cannot read a response from the 8443 origin without an explicit grant.
Opaque origins and the serialisation “null”
Some documents have no tuple at all. A document created by data: navigation, by a sandboxed iframe without allow-same-origin, or by document.open on a document that already had an opaque origin is assigned a fresh opaque origin. RFC 6454 calls these opaque; the HTML standard models them as a unique internal value that is not equal to any other origin, including another opaque origin created a moment earlier.
Serialisation of an opaque origin produces the string null. This is the source of a persistent confusion: two sandboxed frames both report null as their origin, but they are not same-origin with each other and cannot reach one another’s DOM. The string is a debugging label, not an identity. Any server-side logic that trusts an Origin: null header is trusting a value that many unrelated contexts can produce, and the Fetch standard’s CORS protocol does not treat null as a wildcard.
A sandboxed iframe’s origin behaviour is worth stating precisely because it is a common design lever. The sandbox attribute with no tokens applies a set of restrictions that includes treating the content as having an opaque origin. Adding allow-same-origin gives the frame back its real origin — the one derived from its URL — which restores cookie access and DOM access to same-origin peers. The combination allow-scripts allow-same-origin on a frame whose content the parent does not control is generally a mistake: the framed script can then remove the sandbox attribute from its own frame element, because it is same-origin with the parent that owns it, and the remaining restrictions evaporate.
Where the rule is relaxed deliberately
document.domain is the historical exception. Setting it on two documents that share a registrable suffix relaxes their comparison to that suffix. Two documents on a.example.com and b.example.com can both set document.domain to example.com and become same-origin for DOM access. The mechanism is deprecated and its use is restricted: the setter now requires the value to be a suffix of the current host, and a document that has set it is treated as having an origin whose host is that suffix, which means it is no longer same-origin with a document that has not set it. The direction of that last rule surprises people: setting document.domain makes a document less connected to its own unmodified siblings, not more connected to its cousins.
CORS is not an exception to the origin comparison; it is a mechanism for obtaining a response across origins with the comparison still enforced. A cross-origin fetch to a resource that returns Access-Control-Allow-Origin: https://app.example is readable by the page at that exact origin, and only that one. The header value is compared as a serialised origin string, which means the port matters: a page on https://app.example:8443 does not match an allowlist entry of https://app.example. Servers that echo back the request’s Origin header must also send Vary: Origin so that shared caches do not serve one origin’s grant to another.
Simple requests — those with a method among GET, HEAD, POST and a restricted set of headers and content types — are sent without a preflight, and the response is withheld from the script if the headers do not permit it. The request still happened, and any side effect it caused has occurred. This is why CORS is a read-protection mechanism and not a write-protection one; the write-protection job belongs to CSRF defences and the ambient authority of cookies.
Same-origin is not same-site
The SameSite cookie attribute and the Sec-Fetch-Site header operate on a coarser unit: the site, defined as the registrable domain, with a public suffix list deciding where the registrable boundary lies. https://a.example.com and http://b.example.com are cross-origin but same-site, because both reduce to example.com. The scheme is not part of the site computation for this purpose, which is why http://example.com and https://example.com are same-site — a fact that makes an HTTP page on a shared host a meaningful attack surface against the HTTPS site beside it.
The two units are routinely conflated, and the conflation produces two opposite errors. Treating same-site as sufficient for a security decision permits an attacker-controlled subdomain to act with the main site’s authority. Treating cross-origin as cross-site overstates the protection of SameSite=Lax, which still permits top-level navigations from anywhere.
Reading the origin on the server
A server sees the Origin header only on requests the browser chose to label: cross-origin requests, and same-origin POST requests in most browsers. A request with no Origin header is not necessarily same-origin; it may come from a non-browser client, or from a form submission in a context where the header is omitted. Server code that grants access when Origin is absent, on the theory that absence means same-origin, inverts the safe default. The correct posture is to require the header where the check matters, and to reject when it is missing or when it fails to parse.
Header parsing is the other half. Origin serialisations contain a scheme, a host that may be bracketed, and an optional port; splitting on : without accounting for [::1]:8443 produces the wrong host. Comparing parsed origins, using a URL parser on both sides, avoids the class of bug entirely — and the same reasoning governs the address checks that decide what a server may connect to, where a decision taken on the raw string can disagree with the parser that later acts on it.
References
- HTML Living Standard — Origin — WHATWG
- RFC 6454: The Web Origin Concept — IETF
- Fetch Standard — WHATWG