Browsers and the Same-Origin Policy
How browsers keep websites apart: the DOM and rendering, the same-origin policy, cross-origin resource sharing and preflight requests, content security policy, and the developer tools.
What a browser does
A browser looks simple — type an address, see a page — but it is one of the most complex programs on a computer. For each page it:
- Fetches: resolves the name with DNS, opens TCP and TLS connections, sends HTTP requests and handles redirects, cookies and caching (see How the Web Works).
- Parses HTML into a tree of objects in memory. Every element, attribute and piece of text becomes a node.
- Applies CSS: it matches style rules to nodes and computes the final style of each one.
- Runs JavaScript, which can read and change the tree, react to clicks and make further requests.
- Lays out and paints: it calculates where every box goes and draws the pixels — again whenever something changes.
The tree from step 2 is the Document Object Model (DOM): the browser's live, in-memory representation of the page, with an interface through which scripts read and change it. The HTML file is only the starting point. When a script adds a row to a table, the DOM changes and the screen follows, while the HTML source stays the same. That is why "View page source" and the Elements panel of the developer tools can show different things.
Rendering engines
The part of the browser that turns HTML and CSS into pixels is the rendering engine (also called the browser engine). Only three major ones remain:
| Engine | Used by | JavaScript engine |
|---|---|---|
| Blink | Chrome, Edge, Opera, Brave, Samsung Internet | V8 |
| Gecko | Firefox | SpiderMonkey |
| WebKit | Safari; on iOS, other browsers have historically had to use it as well | JavaScriptCore |
According to StatCounter, in September 2026 Chrome alone had around two thirds of browser usage across all devices, and Blink-based browsers together about three quarters, with Safari the clear second. For troubleshooting this means: "it works in Chrome but not in Safari" usually points to an engine difference, while "it works in Chrome but not in Edge" (both Blink) points to settings, extensions, policies or cached data instead.
Web origins
Browsers draw their security boundaries around origins. A
web origin is the combination of
three parts of a URL: the scheme, the
host and the port (RFC 6454, now
defined in the HTML Standard). The path, the query string and the
fragment do not count. When the port is not written, the default for
the scheme applies: 80 for http, 443 for
https.
Compared with https://portal.example.com
|
Same origin? | Why |
|---|---|---|
https://portal.example.com/api/usage |
Yes | Only the path differs |
http://portal.example.com |
No | Different scheme |
https://api.example.com |
No | Different host — subdomains are separate origins |
https://portal.example.com:8443 |
No | Different port |
Comparing origins in Python
urllib.parse.urlsplit() splits a URL into its parts;
the origin is the scheme, the lowercase host name and the port,
filled in with the default when it is missing:
from urllib.parse import urlsplit
DEFAULT_PORTS = {"http": 80, "https": 443}
def origin(url):
"""Scheme, host and port of a URL; the path does not matter."""
parts = urlsplit(url)
scheme = parts.scheme.lower()
port = parts.port or DEFAULT_PORTS[scheme]
return (scheme, parts.hostname, port) # hostname is already lowercase
page = "https://portal.example.com/account/bills"
others = [
"https://portal.example.com/api/usage?month=9",
"https://PORTAL.example.com:443/help",
"http://portal.example.com/account/bills",
"https://api.example.com/v1/outages",
"https://portal.example.com:8443/admin",
]
print("Page origin:", origin(page))
for url in others:
verdict = "same origin" if origin(url) == origin(page) else "cross-origin"
print(f"{verdict:13} {url}")
Page origin: ('https', 'portal.example.com', 443)
same origin https://portal.example.com/api/usage?month=9
same origin https://PORTAL.example.com:443/help
cross-origin http://portal.example.com/account/bills
cross-origin https://api.example.com/v1/outages
cross-origin https://portal.example.com:8443/admin
The second URL looks different but is the same origin: host names
are case-insensitive and 443 is the default port for
https. Browsers serialize an origin as text without a
path, such as https://portal.example.com — that is the
form you will see in the Origin request header below.
The same-origin policy
You are signed in to the customer portal in one tab and open a
random website in another. Your browser holds the portal's session
cookie. If that website's JavaScript could call
https://portal.example.com/account and read the answer,
it could read your account details — the browser would even attach
your cookie. The
same-origin policy
prevents this: a script may freely read data from its own origin,
but not responses from, or the documents of, another origin.
| Across origins, a page… | Examples |
|---|---|
| may embed resources | Images, stylesheets, scripts, fonts, video, frames from other origins are loaded and shown |
| may send requests |
Links, form submissions, redirects — and
fetch() calls are sent too
|
| may not read the result |
The body and most headers of a cross-origin
fetch() response; the DOM of a frame or window
from another origin
|
The middle row matters for security: the policy protects reading, not sending. A malicious page can still make your browser submit a form to another site, with your cookies — which is why cross-site request forgery (CSRF) exists and needs its own defenses. Cookies follow their own, older rules based on domain rather than origin; see Cookies, Sessions and State.
Cross-origin resource sharing (CORS)
Modern sites often need cross-origin reads on purpose: the portal at
https://portal.example.com calls the outage API at
https://api.example.com.
Cross-origin resource sharing (CORS),
defined in the WHATWG Fetch Standard, lets the
server declare which other origins may read its responses.
The browser adds an Origin header to cross-origin
requests; the server answers with
Access-Control-* headers; the browser compares the two
and either hands the response to the script or blocks it.
Simple requests and preflight requests
Some cross-origin requests could already be made before CORS existed, with an ordinary HTML form. For those "simple" requests the browser sends the request directly and checks the response headers afterward. A request is simple when all of these hold:
-
the method is
GET,HEADorPOST; -
it sets only safelisted headers (such as
Accept,Accept-Language,Content-Language,Content-Type); -
a
Content-Type, if present, isapplication/x-www-form-urlencoded,multipart/form-dataortext/plain.
Anything else — a PUT or DELETE, an
Authorization header, a JSON body with
Content-Type: application/json — could do things a form
never could. Before sending such a request the browser asks
permission with a
preflight request: an
OPTIONS request to the same URL that carries the page's
Origin, the intended method in
Access-Control-Request-Method and the non-safelisted
header names in Access-Control-Request-Headers. Only if
the answer allows all of them does the real request follow.
The response headers
| Header | Meaning |
|---|---|
Access-Control-Allow-Origin |
The one origin that may read the response, or
* for any origin (only for requests without
credentials)
|
Access-Control-Allow-Methods |
Preflight answer: methods allowed for the real request |
Access-Control-Allow-Headers |
Preflight answer: request headers the real request may carry |
Access-Control-Allow-Credentials |
true if the browser may send cookies or HTTP
authentication and expose the response; then
* is not allowed and the exact origin must be
named
|
Access-Control-Max-Age |
How many seconds the browser may cache this preflight answer |
Access-Control-Expose-Headers |
Response headers that scripts may read beyond the basic safelisted ones |
Access-Control-Allow-Origin holds a single origin, not
a list. A server that serves several front ends checks the incoming
Origin against its own allowlist and echoes it back
when it matches — and then sends Vary: Origin, so that
caches do not hand one origin's answer to another (see
the Vary header).
A preflight and a GET, answered by a local server
This script starts a tiny outage API on the local machine (Python's
http.server — fine for learning, not meant for
production) that allows one origin. It then plays the browser: it
sends a preflight, the real request, and a request from an origin
that is not on the list, and prints the status and the CORS headers
of each response.
import http.client
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ALLOWED_ORIGINS = {"https://portal.example.com"}
class OutageAPI(BaseHTTPRequestHandler):
def cors_headers(self):
origin = self.headers.get("Origin")
if origin in ALLOWED_ORIGINS:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
def do_OPTIONS(self): # the preflight
self.send_response(204)
self.cors_headers()
if self.headers.get("Origin") in ALLOWED_ORIGINS:
self.send_header("Access-Control-Allow-Methods", "GET, POST")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Max-Age", "600")
self.end_headers()
def do_GET(self):
body = json.dumps({"region": "North", "outages": 2}).encode()
self.send_response(200)
self.cors_headers()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # keep the output clean
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), OutageAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
def show(method, origin, extra=None):
conn = http.client.HTTPConnection("127.0.0.1", server.server_port)
headers = {"Origin": origin, **(extra or {})}
conn.request(method, "/v1/outages", headers=headers)
resp = conn.getresponse()
print(f"{method} from {origin}")
print(f" {resp.status} {resp.reason}")
for name, value in sorted(resp.getheaders()):
if name.startswith("Access-Control") or name == "Vary":
print(f" {name}: {value}")
body = resp.read()
if body:
print(f" body: {body.decode()}")
conn.close()
# 1. The browser asks first: may this origin send GET with an Authorization header?
show("OPTIONS", "https://portal.example.com", {
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
})
# 2. The real request from the allowed origin
show("GET", "https://portal.example.com", {"Authorization": "Bearer demo-token"})
# 3. A simple GET (no preflight needed) from an origin that is not on the list
show("GET", "https://other.example")
server.shutdown()
OPTIONS from https://portal.example.com
204 No Content
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Origin: https://portal.example.com
Access-Control-Max-Age: 600
Vary: Origin
GET from https://portal.example.com
200 OK
Access-Control-Allow-Origin: https://portal.example.com
Vary: Origin
body: {"region": "North", "outages": 2}
GET from https://other.example
200 OK
Vary: Origin
body: {"region": "North", "outages": 2}
Look at the third response: the server answered
200 OK with the full body. Without an
Access-Control-Allow-Origin header, a browser would
refuse to give that body to the script on
other.example — but the request was sent and processed.
CORS controls who may read; it is not access control.
Protect the API itself with authentication and authorization (see
API Authentication).
The classic "CORS error"
A front-end developer reports: "the API is broken, I get a CORS error". The browser console shows something like this:
Access to fetch at 'https://api.example.com/v1/outages' from origin 'https://portal.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
The key insight: the browser did its job. A CORS error means the server's response did not contain the permission the browser needed. Nothing in the front-end code can grant that permission; the fix is on the server (or the proxy in front of it). Work through it like this:
- Read the whole message. It says whether the preflight or the real request failed, and which header was missing or wrong.
-
Open the Network panel and find the
OPTIONSrequest. Did it get a 2xx status with the right headers? A404or405means the server or route does not handleOPTIONS. A401usually means authentication middleware runs before the CORS handling — browsers never send cookies orAuthorizationon a preflight, so it must be answered without them. -
Check the real response's status. Many frameworks
add CORS headers only to successful responses. A
500or401without the header then shows up as a "CORS error" that hides the actual problem: look at the API's logs. -
Compare the origins exactly.
httpversushttps, a missing or extra port (localhost:3000versuslocalhost:5173), or a trailing slash in the configured value (https://portal.example.com/is not an origin) all make the match fail. -
Credentials: with cookies or
credentials: "include", the server must send the exact origin (not*) andAccess-Control-Allow-Credentials: true. -
Duplicate headers: if both the reverse proxy and
the application add
Access-Control-Allow-Origin, the browser sees two values and rejects the response. Configure CORS in one place.
Content Security Policy
The same-origin policy keeps other sites out. It does nothing when an attacker manages to inject a script into your page — cross-site scripting (XSS): that script runs with your origin's full rights. Content Security Policy (CSP) is a response header in which a site lists where its page may load scripts, styles, images, frames and connections from. The browser refuses everything else, so an injected script — or a script from an unexpected domain — does not run.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64Value9xQ2w=='; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
-
default-src 'self': by default, load resources only from the page's own origin. -
script-src 'self' 'nonce-…': scripts from the own origin, plus inline scripts that carry this random, per-response nonce (at least 128 bits). Injected inline scripts do not know the nonce and are blocked. -
object-src 'none'andbase-uri 'self': close two classic side doors (plugins and a rewritten base URL). -
frame-ancestors 'none': no other site may show this page in a frame, which stops clickjacking.
CSP is defense in depth: the real fix for XSS is output encoding
(see
the fix: output encoding), and CSP limits the damage when that fails somewhere. Roll it out
with Content-Security-Policy-Report-Only first, which
reports violations without blocking. The current specification, CSP
Level 3, is a W3C Working Draft (as of September 2026); browsers
support most of it. CSP is one of several protective response
headers described in
security headers.
Browser developer tools
Every desktop browser has built-in browser developer tools (DevTools): panels that show what the browser actually requested, received, ran and stored for the current page. Open them with F12 or Ctrl+Shift+I (Cmd+Option+I on a Mac). For anyone who supports web applications they are the first tool to reach for, before any server log.
| Panel | What it shows | Use it for |
|---|---|---|
| Network | Every request: method, URL, status, request and response headers, body, size, timing, whether it came from cache | Failing API calls, redirects, slow pages, preflights, caching. "Preserve log" keeps entries across page loads; "Disable cache" bypasses the browser cache while DevTools is open; "Copy as cURL" turns a request into a command you can rerun |
| Console | JavaScript errors and the browser's own warnings | CORS, mixed content and CSP violations, script errors |
| Elements (Inspector in Firefox) | The live DOM and the CSS applied to each element | "The button is there but invisible", layout problems |
| Application (Storage in Firefox) | Cookies, local and session storage, cache storage, service workers | Session problems, stale data, clearing one site's data |