Origins, Schemes, and Ports
We've been throwing the word "origin" around for two chapters. Time to get precise, because the browser is extremely precise about this, and the edge cases will bite you in ways you didn't think were possible.
The Formal Definition
An origin is defined by RFC 6454 as the tuple of:
- Scheme (also called "protocol"):
http,https,ftp, etc. - Host: the full hostname, e.g.,
example.com,api.example.com - Port: the TCP port number, e.g.,
80,443,8080
Two resources have the same origin if and only if all three components are identical. Not "similar." Not "related." Identical.
The browser computes the origin of a page from its URL:
https://www.example.com:443/path/to/page?query=1#fragment
└─┬──┘ └──────┬───────┘└┬┘└──────────────────────────┘
scheme host port (path, query, fragment
are NOT part of origin)
Notice: the path, query string, and fragment are not part of the origin. These two URLs have the same origin:
https://example.com/page-one
https://example.com/page-two/subpage?x=1#section
Both are (https, example.com, 443).
Same-Origin vs Cross-Origin: The Comparison Table
Let's look at concrete comparisons. Given a page loaded from
https://www.example.com/page:
| URL being accessed | Same origin? | Reason |
|---|---|---|
https://www.example.com/other | Yes | Same scheme, host, port |
https://www.example.com/dir/page | Yes | Path doesn't matter |
http://www.example.com/page | No | Different scheme (http vs https) |
https://example.com/page | No | Different host (no www) |
https://api.example.com/page | No | Different host (different subdomain) |
https://www.example.com:8443/page | No | Different port (8443 vs 443) |
https://www.example.com:443/page | Yes | Port 443 is default for https |
That last row catches people off guard. The default port for https is 443, so
https://www.example.com and https://www.example.com:443 are the same origin. The
browser normalizes this. More on default ports in a moment.
Let's do another set. Given a page at http://localhost:3000:
| URL being accessed | Same origin? | Reason |
|---|---|---|
http://localhost:3000/api/data | Yes | Same scheme, host, port |
http://localhost:8080/api/data | No | Different port |
https://localhost:3000/api/data | No | Different scheme |
http://127.0.0.1:3000/api/data | No | Different host! |
That last one. Read it again. localhost and 127.0.0.1 resolve to the same IP
address, but the browser doesn't care about IP resolution. It compares the strings
in the hostname. localhost !== 127.0.0.1, therefore different origin, therefore
cross-origin. I've seen this cause confusion in local development setups more times
than I can count.
# Demonstrate with curl — both reach the same server:
$ curl http://localhost:3000/api/health
{"status": "ok"}
$ curl http://127.0.0.1:3000/api/health
{"status": "ok"}
# But in the browser, a page at http://localhost:3000
# making a fetch to http://127.0.0.1:3000 is cross-origin.
Default Ports
The browser has default port numbers for common schemes:
| Scheme | Default Port |
|---|---|
http | 80 |
https | 443 |
ftp | 21 |
When the URL doesn't specify a port, the browser uses the default. And here's the important bit: the browser normalizes the origin to omit the default port.
These are the same origin:
https://example.com → origin: (https, example.com, 443)
https://example.com:443 → origin: (https, example.com, 443)
These are the same origin:
http://example.com → origin: (http, example.com, 80)
http://example.com:80 → origin: (http, example.com, 80)
But this is a different origin from both of the above:
http://example.com:8080 → origin: (http, example.com, 8080)
You can check what the browser thinks the origin is with:
console.log(window.location.origin);
// "https://example.com" (note: no :443)
Or via the Origin header that the browser sends with cross-origin requests. Let's
look at it with a request in Chrome DevTools:
- Open Network tab
- Make a cross-origin fetch
- Click on the request
- In the Request Headers section, look for
Origin:
No port number, becauseOrigin: https://my-app.comhttpsdefaults to 443.
If your app runs on a non-standard port:
Origin: http://localhost:3000
Port included, because 3000 is not the default for http.
Why http and https Are Different Origins
This is not just academic. In practice, this comes up when:
- You're migrating from HTTP to HTTPS
- Your dev environment uses HTTP but production uses HTTPS
- You have a mixed-content situation
// Page loaded over https://example.com
// This is cross-origin:
fetch('http://example.com/api/data');
// Different scheme: https vs http
The browser console will show:
Mixed Content: The page at 'https://example.com' was loaded over HTTPS, but
requested an insecure resource 'http://example.com/api/data'. This request has
been blocked; the content must be served over HTTPS.
Note: this isn't even a CORS error — it's a mixed content error, which is even stricter. Modern browsers block HTTP requests from HTTPS pages entirely (for active content like fetch/XHR). The origin difference is almost beside the point.
But consider the reverse: a page at http://example.com fetching from
https://example.com. That's also cross-origin. The browser may allow it (it's not
a mixed-content downgrade), but it's still cross-origin, so CORS headers are needed.
# Verify the origin difference with curl
$ curl -s -D - -o /dev/null https://example.com/api \
-H "Origin: http://example.com"
# The server needs Access-Control-Allow-Origin: http://example.com
# (note the http, not https)
# If the server responds with:
# Access-Control-Allow-Origin: https://example.com
# ...that won't match, and the browser will block the response.
Why www.example.com and example.com Are Different Origins
This is a perennial source of pain. The host portion of the origin is compared as an exact string match. No DNS resolution. No "well, they're the same domain." Just string equality.
https://example.com → host: "example.com"
https://www.example.com → host: "www.example.com"
Different strings. Different origins. End of discussion.
If your site is accessible at both example.com and www.example.com, and your
API is at api.example.com, you need your CORS configuration to handle both
origins:
# Request from example.com:
$ curl -s -D - -o /dev/null https://api.example.com/data \
-H "Origin: https://example.com"
# Needs to respond with:
# Access-Control-Allow-Origin: https://example.com
# Request from www.example.com:
$ curl -s -D - -o /dev/null https://api.example.com/data \
-H "Origin: https://www.example.com"
# Needs to respond with:
# Access-Control-Allow-Origin: https://www.example.com
Most CORS middleware handles this by checking the incoming Origin header against an
allow-list and reflecting the matched origin back. We'll cover this pattern in detail
in later chapters.
Recommendation: Pick one canonical URL (either www or bare domain) and redirect
the other to it. This reduces your CORS configuration surface and avoids cookie-scoping
headaches.
Subdomains Are Cross-Origin
This is the one that gets people every time, especially in microservice architectures where you've carved up your system into subdomains:
https://app.mycompany.com (the frontend SPA)
https://api.mycompany.com (the REST API)
https://auth.mycompany.com (the auth service)
https://cdn.mycompany.com (static assets)
Every single one of these is a different origin. Your frontend at
app.mycompany.com making a fetch to api.mycompany.com is cross-origin. You need
CORS headers. There is no "same parent domain" exception.
// Page at https://app.mycompany.com
fetch('https://api.mycompany.com/v1/users')
.then(r => r.json())
.then(console.log);
// Cross-origin. Needs CORS. Every time.
Some developers discover this and think: "I'll just put everything on one subdomain
and use path-based routing." That's a valid architectural choice. Putting your API
at https://mycompany.com/api/ and your frontend at https://mycompany.com/ means
everything is same-origin. No CORS needed. But this has its own tradeoffs (shared
cookies, shared CSP, deployment coupling), which we won't get into here.
The Null Origin
There's a special origin value: null. Not the string "null" — well, actually,
it is the string "null" in HTTP headers, but it represents the concept of "no
meaningful origin."
The null origin appears in several situations:
-
Pages loaded from
file://URLs: Open an HTML file from your filesystem (not served by a local web server). Its origin isnull.// Open file:///Users/you/test.html in Chrome console.log(window.location.origin); // "null" -
Pages loaded from
data:URLs:// A data: URL has a null origin const iframe = document.createElement('iframe'); iframe.src = 'data:text/html,<script>console.log(origin)</script>'; document.body.appendChild(iframe); // The iframe's origin is "null" -
Sandboxed iframes (without
allow-same-origin):<iframe sandbox src="https://example.com"></iframe> <!-- The iframe's origin is forced to "null" --> -
Redirected requests in some cases: certain cross-origin redirects cause the
Originheader to be set tonull.
Why you should never allow the null origin
You might be tempted to do this:
Access-Control-Allow-Origin: null
Don't. The problem is that many different contexts produce a null origin. If
your server allows the null origin, you're allowing access from any file:// page,
any data: URL, any sandboxed iframe. That's far too broad.
# Testing what happens with the null origin:
$ curl -s -D - -o /dev/null https://api.example.com/data \
-H "Origin: null"
# If the server responds with:
# Access-Control-Allow-Origin: null
# ...then ANY null-origin context can read the response.
# This is almost certainly not what you want.
The null origin is essentially the "I don't know where this came from" origin, and "I trust things from unknown origins" is not a great security policy.
about:blank and Inherited Origins
about:blank pages (and about-scheme documents in general) inherit the origin of
their creator:
// Page at https://example.com
const win = window.open('about:blank');
// win.origin === 'https://example.com'
// The new window has the same origin as the page that opened it
win.document.write('<h1>Same origin as parent</h1>');
// This works because they share an origin
This is specified behavior and generally does what you'd expect. But it means you
can't use about:blank as a way to create an "originless" context — it inherits
from whoever created it.
file:// URLs: Here Be Dragons
Local files opened in the browser (file:///Users/you/index.html) are a special mess.
The behavior varies by browser:
Chrome/Chromium
Every file:// URL gets the null origin. Two HTML files in the same directory
cannot access each other via JavaScript — they're both null, but null is not
considered equal to null for same-origin checks. Yes, really.
// file:///Users/you/page-a.html
console.log(window.location.origin); // "null"
// Trying to fetch a file in the same directory:
fetch('file:///Users/you/data.json')
.catch(err => console.error(err));
// Blocked. null origin cannot read from file:// URLs.
// Chrome console:
Access to fetch at 'file:///Users/you/data.json' from origin 'null' has been
blocked by CORS policy: Cross origin requests are only supported for protocol
schemes: http, data, isolated-app, chrome-extension, chrome, https,
chrome-untrusted.
Firefox
Firefox has historically been more permissive with file:// URLs, allowing same-
directory access. But this behavior has been tightened in recent versions and varies
with configuration.
The practical takeaway
Don't open HTML files directly in the browser for development. Use a local web server:
# Python
$ python3 -m http.server 8000
# Node.js (npx, no install needed)
$ npx serve .
# PHP
$ php -S localhost:8000
Now your page is at http://localhost:8000 and has a proper origin. This eliminates
an entire class of confusing CORS errors that have nothing to do with your actual
application.
data: URLs and blob: URLs
data: URLs
Pages and resources loaded via data: URLs get a null origin, as mentioned above.
They're essentially "originless" — which means they're cross-origin with everything,
including other data: URLs.
// This creates an iframe with a null origin
const html = '<script>parent.postMessage(document.domain, "*")</script>';
const iframe = document.createElement('iframe');
iframe.src = `data:text/html,${encodeURIComponent(html)}`;
document.body.appendChild(iframe);
blob: URLs
blob: URLs inherit the origin of the document that created them:
// Page at https://example.com
const blob = new Blob(['<h1>Hello</h1>'], { type: 'text/html' });
const url = URL.createObjectURL(blob);
console.log(url);
// "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"
// The origin portion of the blob URL matches the creating page's origin
// This blob has origin https://example.com
This is how things like web workers loaded from inline code work — you create a blob URL, and it has the same origin as your page, so same-origin restrictions don't apply.
document.domain: Deprecated But Haunting
Once upon a time, there was a way to loosen the SOP between subdomains.
document.domain allowed two pages on different subdomains to declare a common parent
domain and become "same-origin" for DOM access:
// Page at https://app.example.com
document.domain = 'example.com';
// Page at https://admin.example.com (in an iframe)
document.domain = 'example.com';
// Now both pages can access each other's DOM
// because they've agreed on a common super-domain
This was widely used in the 2000s and 2010s. It's now deprecated and in the process of being removed from browsers.
Chrome began disabling document.domain setter by default in Chrome 115 (2023).
You can still re-enable it via the Origin-Agent-Cluster header, but you shouldn't
be building new things that depend on it.
Why was it deprecated?
Because it's a security hazard. If evil.example.com sets document.domain = 'example.com', it could access the DOM of bank.example.com (if bank also set
document.domain). The feature essentially allowed any subdomain to weaken the
security boundary for all subdomains of the same parent domain.
If you encounter document.domain in legacy code, the migration path is:
- For cross-origin DOM access: Use
postMessage()instead. - For cross-origin API calls: Use CORS.
- For cookie sharing: Use cookie
Domainattribute:Set-Cookie: session=abc; Domain=.example.com
// Modern replacement for document.domain DOM access:
// Parent page (https://app.example.com):
window.addEventListener('message', (event) => {
if (event.origin !== 'https://admin.example.com') return;
console.log('Got message:', event.data);
});
// Iframe (https://admin.example.com):
parent.postMessage('Hello from admin', 'https://app.example.com');
Origin vs Referer Header
Both the Origin and Referer (yes, it's misspelled — the HTTP spec locked in the
typo from RFC 1945 in 1996 and we're stuck
with it forever) headers tell the server where a request came from. But they serve
different purposes and contain different information.
The Origin header
Origin: https://app.example.com
- Contains only the origin: scheme + host + port
- No path, no query string, no fragment
- Sent on cross-origin requests (fetch, XHR) and same-origin POST requests
- Sent on CORS preflight requests
- Primarily used for CORS and CSRF protection
- Cannot be spoofed by JavaScript in a browser (the browser sets it)
The Referer header
Referer: https://app.example.com/dashboard?user=alice
- Contains the full URL (or a portion of it, depending on referrer policy)
- Includes the path and query string (by default)
- Sent on most requests (navigations, subresource loads, fetch/XHR)
- Can be controlled by
Referrer-Policyheader orreferrerpolicyattribute - Primarily used for analytics, logging, and back-button behavior
Key differences
| Feature | Origin | Referer |
|---|---|---|
| Contains path? | No | Yes (by default) |
| Sent on same-origin GET? | No | Yes |
| Sent on cross-origin requests? | Yes | Yes (configurable) |
| Used for CORS? | Yes | No |
| Can be suppressed? | Not by page JavaScript | Yes, via Referrer-Policy |
| Misspelled? | No | Yes, forever |
Practical example
# When your page at https://app.example.com/dashboard makes a
# cross-origin POST to https://api.example.com/data:
$ curl -v https://api.example.com/data \
-X POST \
-H "Origin: https://app.example.com" \
-H "Referer: https://app.example.com/dashboard"
# The Origin header tells the server: "This request came from
# the origin https://app.example.com"
# The Referer header tells the server: "Specifically, it came
# from the /dashboard page"
Why CORS uses Origin, not Referer
The Origin header was designed specifically for CORS and security checks because:
-
It can't be suppressed. The
Refererheader can be stripped by referrer policies, browser extensions, or privacy settings. TheOriginheader on cross-origin requests is always set by the browser and cannot be overridden by JavaScript. -
It doesn't leak path information. The server only needs to know which origin is making the request to make a CORS decision. It doesn't need to know which specific page. The
Originheader respects the principle of least privilege. -
It's simpler to match. Comparing
https://app.example.comagainst an allow-list is easier and less error-prone than parsing a full URL from aRefererheader.
Checking the Origin in Practice
You can see origins in action using your browser's DevTools:
Checking your page's origin
Open the Console and type:
window.location.origin
// "https://app.example.com"
// Or more formally:
new URL(window.location.href).origin
// "https://app.example.com"
Checking the Origin header on requests
- Open DevTools Network tab
- Make a cross-origin request
- Click on the request
- Look at Request Headers:
Accept: application/json
Origin: https://app.example.com
Referer: https://app.example.com/page
Comparing origins in JavaScript
function isSameOrigin(url1, url2) {
const a = new URL(url1);
const b = new URL(url2);
return a.origin === b.origin;
}
isSameOrigin('https://example.com/a', 'https://example.com/b');
// true
isSameOrigin('https://example.com', 'http://example.com');
// false
isSameOrigin('http://localhost:3000', 'http://localhost:8080');
// false
isSameOrigin('http://localhost:3000', 'http://127.0.0.1:3000');
// false — hostname string comparison
The URL constructor's .origin property is the canonical way to get and compare
origins in JavaScript. Don't try to parse URLs with regex. Life's too short, and
URLs are too weird.
Summary: The Origin Checklist
When you're debugging a CORS issue, the first thing to verify is whether the request is actually cross-origin. Here's the checklist:
-
What is the page's origin? Check
window.location.origin. -
What is the request target's origin? Construct it from the URL's scheme, host, and port.
-
Do they match exactly?
- Scheme must be identical (
http!==https) - Host must be identical (
www.example.com!==example.com) - Port must be identical (account for defaults: 80 for http, 443 for https)
- Scheme must be identical (
-
If they don't match, you need CORS headers.
-
Edge cases to watch for:
localhost!==127.0.0.1file://pages havenullorigin- Subdomains are different origins
- Trailing slashes in paths don't affect origin (paths aren't part of origin)
Now that we know exactly what an origin is and how the browser determines whether a request is cross-origin, we're ready to look at how CORS actually works — the headers, the preflight dance, and all the things that can go wrong. That's coming up next.