Web Security (OWASP Top 10:2025) Module 1 – Foundations: the web, standards and threat thinking
HTTP, origins and the browser security model
Read raw HTTP, tell same-origin from same-site, see what the same-origin policy stops and what it lets through, and why cookies ignore ports and schemes.
What you will learn
- Read a raw HTTP request and response and separate what the browser sets from what any client can write
- Classify two URLs as same-origin, same-site or cross-site
- Explain what the same-origin policy blocks (reading) and what it allows (sending and embedding)
- Explain how cookies are scoped and why they are not isolated by port, scheme or sibling subdomain
- Decide which checks must run on the server, whatever the page does
Before you start
What you’ll learn · Before you start
What you will learn
- Read a raw HTTP request and response and separate what the browser sets from what any client can write
- Classify two URLs as same-origin, same-site or cross-site
- Explain what the same-origin policy blocks (reading) and what it allows (sending and embedding)
- Explain how cookies are scoped and why they are not isolated by port, scheme or sibling subdomain
- Decide which checks must run on the server, whatever the page does
Before you start
On this page
The order request below has seven header lines, and the shop’s own page may set only one of them, Content-Type;
the browser fills in the other six. A script outside any browser can write all seven, byte for byte, which is why a
server can trust a header only for what an honest browser promises its user. Origins, sites and cookie scopes are the
rest of that promise: they decide which requests carry your users’ cookies and which pages may read the answers.
You will take that request apart, sort 13 pairs of addresses into same origin, same site and cross site, follow six Set-Cookie headers to see where each cookie goes, and end with the rule that follows from all of it: the server checks every request itself.
An HTTP request is text that any client can write
Here is an order request as the shop’s page sends it with fetch(), and the server’s answer. The program reads both
and says who controls each header:
"""Read a raw HTTP request and response, and say who controls each header.
The forbidden request-header names come from the Fetch standard: a browser never lets page script set them. That
protects the browser's user. It does not stop any other HTTP client, which can send every byte it likes.
"""
FORBIDDEN = {
"accept-charset", "accept-encoding", "access-control-request-headers", "access-control-request-method",
"connection", "content-length", "cookie", "cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"referer", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "via",
}
def forbidden(name):
n = name.lower()
return n in FORBIDDEN or n.startswith(("proxy-", "sec-"))
def parse(raw):
head, _, body = raw.partition("\n\n")
first, *lines = head.split("\n")
headers = [tuple(part.strip() for part in line.split(":", 1)) for line in lines]
return first, headers, body
from_page = """POST /api/orders HTTP/1.1
Host: shop.example
Origin: https://shop.example
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Cookie: sid=FAKE-session-7f3a
Content-Type: application/json
Content-Length: 28
{"item": "tee-42", "qty": 1}
"""
response = """HTTP/1.1 201 Created
Content-Type: application/json
Set-Cookie: sid=FAKE-session-7f3a; Secure; HttpOnly; SameSite=Lax; Path=/
Cache-Control: no-store
{"order": "ord-5531", "qty": 1}
"""
first, headers, body = parse(from_page)
print("Request, as the shop's own page sends it:", first)
for name, value in headers:
who = "the browser sets it; page script cannot" if forbidden(name) else "page script may set it"
print(f" {name:<15} {who}")
print(f" body page script chooses it ({len(body.strip())} bytes)")
first, headers, body = parse(response)
print("Response:", first)
for name, value in headers:
print(f" {name:<15} the server sets it")
# Any HTTP client outside a browser, such as a script on a laptop, can build the same request line by line,
# forbidden headers included: the Fetch standard's rules bind browsers only.
lines = [
"POST /api/orders HTTP/1.1",
"Host: shop.example",
"Origin: https://shop.example",
"Sec-Fetch-Site: same-origin",
"Sec-Fetch-Mode: cors",
"Cookie: sid=FAKE-session-7f3a",
"Content-Type: application/json",
"Content-Length: 28",
]
typed_by_a_script = "\n".join(lines) + '\n\n{"item": "tee-42", "qty": 1}\n'
claimed = [line.split(":")[0] for line in lines[1:] if forbidden(line.split(":")[0])]
print("A script outside a browser builds the same request:", typed_by_a_script == from_page)
print("Forbidden headers it wrote itself:", ", ".join(claimed)) Output
Request, as the shop's own page sends it: POST /api/orders HTTP/1.1 Host the browser sets it; page script cannot Origin the browser sets it; page script cannot Sec-Fetch-Site the browser sets it; page script cannot Sec-Fetch-Mode the browser sets it; page script cannot Cookie the browser sets it; page script cannot Content-Type page script may set it Content-Length the browser sets it; page script cannot body page script chooses it (28 bytes) Response: HTTP/1.1 201 Created Content-Type the server sets it Set-Cookie the server sets it Cache-Control the server sets it A script outside a browser builds the same request: True Forbidden headers it wrote itself: Host, Origin, Sec-Fetch-Site, Sec-Fetch-Mode, Cookie, Content-Length
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 raw_http_reader.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Six of the seven request headers are what the Fetch standard calls
forbidden request header: A request header that browsers never let page script set or change, such as Host, Origin, Cookie and every name that starts with Sec- or Proxy- (the Fetch standard). Clients outside a browser can send any of them.: Host, Origin, Cookie, Content-Length
and the two Sec-Fetch headers, together with every name that starts with Sec- or Proxy-. A browser never lets
page script set them, so a page cannot claim another origin or send another site’s cookies. The last two lines of the
output are the other half of the story: a script outside any browser built the same request line by line, and the six
forbidden headers were simply text it wrote. Step through both senders:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The page asks. The shop's page calls fetch() to place an order. Page script chooses the method, the address, the body and Content-Type. Everything else in the request is the browser's to fill in.
- The browser sends. The browser adds Host, Origin, the Sec-Fetch headers and the session Cookie. The Fetch standard lists these as forbidden request headers, so page script cannot set or change them.
- The server answers. The response's headers are the server's. Set-Cookie stores the session, Cache-Control says no copy may be kept, and the browser obeys both on its user's behalf.
- The same bytes again. A script on a laptop writes the same request line by line, the forbidden headers included. The rules that forbid them bind browsers, and this client is not a browser.
- What the server can know. Both requests are the same bytes, so no header says which sender wrote it. The server decides with what it can check itself, on every request. Origin and Sec-Fetch-Site still help, because honest browsers fill them in for their users.
The browser’s rules protect the person using the browser, and bind nothing else. So treat Origin and
Sec-Fetch-Site as what an honest browser reports on its user’s behalf. That helps against other sites open in the
same browser, and it is never proof of who is calling.
Trusting a header because browsers set it
“Only our own page can send Origin: https://shop.example, so requests that carry it are ours” holds for honest
browsers and for nobody else. Decide what a request may do from a session your server issued and checks on every
request; use Origin and Sec-Fetch-Site as an extra signal on top.
Checkpoint
Check: Who writes each header
Origin is scheme, host and port; site is scheme and registrable domain
Browsers decide most questions by origin: The scheme, host and port of a URL, such as https://shop.example.com with the default port 443. Two URLs share an origin only when all three match; the path, the query and any user name are not part of it.: three parts of a URL, the scheme, the host and the
port (RFC 6454 and the HTML standard). The path, the query and any user name in the URL are not part of it, and a
missing port means the scheme’s default, 443 for https. A site is coarser. It is the scheme and the
registrable domain: The part of a host one label longer than its public suffix, such as example.com for shop.example.com. Two hosts with the same registrable domain and the same scheme are the same site., the part of the host one label longer than its
public suffix: A name under which separate parties register their own names, such as com, or a hosting name such as github.io whose users each get a subdomain. The Public Suffix List records them, and browsers refuse cookies set for one.. The Public Suffix List says what counts as one: com and org do, and
so do hosting names such as github.io, where every user gets a subdomain of their own. This program sorts 13 pairs
with the WHATWG URL parser that browsers follow, and a small sample of the suffix list:
// Same origin, same site or cross site, for pairs of URLs: the WHATWG URL parser gives each URL its origin
// (scheme, host, port); the site is the scheme and the registrable domain, as the HTML standard defines it.
//
// PUBLIC_SUFFIXES is a small sample in the shape of the Public Suffix List. pages.test stands for a hosting
// service whose users each get a subdomain, as github.io is on the real list; .test names are reserved (RFC 2606).
const PUBLIC_SUFFIXES = new Set(['com', 'org', 'test', 'pages.test']);
const isIp = (host) => /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith('[');
// The registrable domain: the longest public suffix plus one more label, or null (for an IP address, or a host
// that is a public suffix itself). A trailing dot stays on, as the URL standard keeps it.
function registrableDomain(host) {
if (isIp(host)) return null;
const dot = host.endsWith('.') ? '.' : '';
const labels = (dot ? host.slice(0, -1) : host).split('.');
let suffix = 1;
for (let i = 0; i < labels.length; i++) {
if (PUBLIC_SUFFIXES.has(labels.slice(i).join('.'))) {
suffix = labels.length - i;
break;
}
}
return labels.length > suffix ? labels.slice(-(suffix + 1)).join('.') + dot : null;
}
function site(url) {
const { origin } = new URL(url);
if (origin === 'null') return null; // an opaque origin: same site with nothing else
const { protocol, hostname } = new URL(origin);
return `${protocol}//${registrableDomain(hostname) ?? hostname}`;
}
function relation(a, b) {
const originA = new URL(a).origin;
const originB = new URL(b).origin;
if (originA !== 'null' && originA === originB) return 'same origin';
const siteA = site(a);
return siteA !== null && siteA === site(b) ? 'same site' : 'cross site';
}
const page = 'https://shop.example.com/cart';
const pairs = [
[page, 'https://shop.example.com/orders?id=7'],
[page, 'https://SHOP.EXAMPLE.COM:443/help'],
[page, 'https://reader@shop.example.com/'],
[page, 'https://shop.example.com:8443/'],
[page, 'https://api.example.com/v1/orders'],
[page, 'https://example.com/'],
[page, 'http://shop.example.com/cart'],
[page, 'https://shop.example.com./cart'],
[page, 'https://shop.example.org/'],
[page, 'blob:https://shop.example.com/550e8400-e29b-41d4-a716-446655440000'],
[page, 'data:text/html,hello'],
['https://alice.pages.test/', 'https://bob.pages.test/'],
['http://localhost:3000/', 'http://127.0.0.1:3000/'],
];
console.log(`origin of ${page}: ${new URL(page).origin}`);
for (const [a, b] of pairs) {
const from = a === page ? '' : `${new URL(a).origin} and `;
console.log(`${relation(a, b).padEnd(12)} ${from}${b} (origin ${new URL(b).origin})`);
} Output
origin of https://shop.example.com/cart: https://shop.example.com same origin https://shop.example.com/orders?id=7 (origin https://shop.example.com) same origin https://SHOP.EXAMPLE.COM:443/help (origin https://shop.example.com) same origin https://reader@shop.example.com/ (origin https://shop.example.com) same site https://shop.example.com:8443/ (origin https://shop.example.com:8443) same site https://api.example.com/v1/orders (origin https://api.example.com) same site https://example.com/ (origin https://example.com) cross site http://shop.example.com/cart (origin http://shop.example.com) cross site https://shop.example.com./cart (origin https://shop.example.com.) cross site https://shop.example.org/ (origin https://shop.example.org) same origin blob:https://shop.example.com/550e8400-e29b-41d4-a716-446655440000 (origin https://shop.example.com) cross site data:text/html,hello (origin null) cross site https://alice.pages.test and https://bob.pages.test/ (origin https://bob.pages.test) cross site http://localhost:3000 and http://127.0.0.1:3000/ (origin http://127.0.0.1:3000)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node origin_site_table.mjs
Read the rows in groups. The first three are the same origin: only the path differs, case and the default port are
normalised away, and a user name is not part of an origin. Another port, a sibling host and the parent domain are
same site: Two origins are the same site when their schemes and their registrable domains match: https://shop.example.com and https://api.example.com are the same site but different origins. Ports do not count. but a different origin. The scheme counts for both, so
http://shop.example.com is cross site from the https page. So is shop.example.com. with a trailing dot: it is a
different host, and its registrable domain is example.com., a pair the HTML standard itself lists as cross site.
The last four rows are the unusual ones. A blob: URL has the origin of the page that made it, and a data: URL has
an opaque origin that matches nothing. alice.pages.test and bob.pages.test are cross site because pages.test is a
public suffix in the sample, as github.io is in the real list. localhost and 127.0.0.1 may reach the same
machine, but they are different hosts, so they are different origins and different sites.
Key idea
An origin is the scheme, the host and the port; a site is the scheme and the registrable domain. Use the origin for security decisions wherever you can: the URL standard warns that suffix lists differ from client to client, so a site is not a hard boundary.
Checkpoint
Check: Origins and sites
The same-origin policy limits reading, not sending
The same-origin policy: The browser rule that a page's script may read responses and documents only from its own origin, unless the other origin allows it with CORS. It limits reading; requests to other origins are still sent. is the browser’s rule for what a page may do with another origin. MDN sums it up in three kinds of interaction:
| What the page does | Examples | With another origin |
|---|---|---|
| Writes | Following a link, a redirect, submitting a form | Typically allowed |
| Embeds | An image, a script, a stylesheet, a frame | Typically allowed |
| Reads | Reading a response in script, reading another page’s document | Typically blocked, unless the other origin allows it with CORS |
So the policy decides what a page’s script may read, and only that. A request to another origin is still sent, and
a blocked read means the script never sees the answer, not that the request never happened. When the browser sends
such a request it may attach the cookies it holds for the other site, unless the cookie’s SameSite attribute keeps
them off. MDN describes the three values:
SameSite |
Sent on requests from another site |
|---|---|
Strict |
Never |
Lax |
Only on top-level navigations with a safe method, such as following a link |
None (needs Secure) |
On every request, unless the browser blocks third-party cookies |
Browsers differ on that last condition: Firefox and Safari restrict third-party cookies by default, Chrome does not,
so a server cannot count on either behaviour. The consequence for the server is simple. A request that changes
something, such as placing or cancelling an order, may reach it from a page on another site, and the server decides
whether to act on it. The access-control module builds the defences on that point: SameSite cookies, tokens that
another site cannot read, and checks of Origin and Sec-Fetch-Site.
Key idea
The same-origin policy keeps another origin’s script from reading responses; it does not stop requests from being sent, so every request that changes something is the server’s to accept or refuse.
Checkpoint
Check: What the same-origin policy blocks
Cookies follow domain and path, not port or scheme
Cookies do not follow origins: they have scoping rules of their own (RFC 6265). A cookie set without
a Domain attribute is a host-only cookie: A cookie set without a Domain attribute. The browser sends it back only to the exact host that set it, not to its parent domain or to sibling subdomains., sent back only to the host that set
it. With Domain=example.com it goes to example.com and every name under it. Path narrows it to part of the site,
and Secure keeps it to https. Ports appear in none of these rules. Follow six Set-Cookie headers through them,
then four requests:
"""Which cookies a browser keeps, and which it sends back: the storage and sending rules of RFC 6265 (sections 5.3
and 5.4) with the __Host- prefix rule of RFC 6265bis. Ports play no part in any rule, which is the point of row 2.
PUBLIC_SUFFIXES is a small sample in the shape of the Public Suffix List."""
from urllib.parse import urlsplit
PUBLIC_SUFFIXES = {"com", "org", "test"}
jar = []
def domain_match(host, domain):
return host == domain or host.endswith("." + domain)
def store(url, header):
"""Apply one Set-Cookie header received from url, or say why the browser ignores it."""
parts = urlsplit(url)
host, secure_page = parts.hostname, parts.scheme == "https"
name_value, *attrs = [p.strip() for p in header.split(";")]
name, value = name_value.split("=", 1)
flags = {}
for a in attrs:
key, _, val = a.partition("=")
flags[key.lower()] = val.lower().lstrip(".")
domain = flags.get("domain")
if domain in PUBLIC_SUFFIXES and domain != host:
return f"ignored: Domain={domain} is a public suffix"
if name.startswith("__Host-") and (domain or "secure" not in flags or flags.get("path") != "/" or not secure_page):
return "ignored: a __Host- cookie needs Secure, Path=/ and no Domain"
if domain and not domain_match(host, domain):
return f"ignored: {host} is not under {domain}"
cookie = {"name": name, "value": value, "domain": domain or host, "host_only": not domain,
"path": flags.get("path", "/"), "secure": "secure" in flags}
replaced = [c for c in jar if (c["name"], c["domain"], c["path"]) == (name, cookie["domain"], cookie["path"])]
for c in replaced:
jar.remove(c)
jar.append(cookie)
scope = f"only {host}" if cookie["host_only"] else f"{cookie['domain']} and every name under it"
return ("replaced the old value; " if replaced else "stored; ") + "sent to " + scope
def sent_to(url):
parts = urlsplit(url)
host, path = parts.hostname, parts.path or "/"
out = []
for c in jar:
if c["host_only"] and host != c["domain"]:
continue
if not c["host_only"] and not domain_match(host, c["domain"]):
continue
if not (path == c["path"] or path.startswith(c["path"].rstrip("/") + "/")):
continue
if c["secure"] and parts.scheme != "https":
continue
out.append(f"{c['name']}={c['value']}")
return "; ".join(out) or "(no cookies)"
responses = [
("https://shop.example.com/login", "sid=FAKE-s1; Secure; HttpOnly; Path=/"),
("https://shop.example.com/", "theme=dark; Domain=example.com; Path=/"),
("https://blog.example.com/", "theme=light; Domain=example.com; Path=/"),
("https://shop.example.com/", "__Host-cart=FAKE-c9; Secure; Path=/"),
("https://blog.example.com/", "__Host-cart=FAKE-x; Secure; Path=/; Domain=example.com"),
("https://shop.example.com/", "promo=1; Domain=com; Path=/"),
]
print("Set-Cookie headers, in order:")
for url, header in responses:
print(f" {urlsplit(url).hostname:<17} {header.split(';')[0]:<20} {store(url, header)}")
print("Cookie header of each request:")
for i, url in enumerate(["https://shop.example.com/cart", "https://shop.example.com:8443/cart",
"http://shop.example.com/cart", "https://api.example.com/"], start=1):
print(f" {i}. {url:<36} {sent_to(url)}") Output
Set-Cookie headers, in order: shop.example.com sid=FAKE-s1 stored; sent to only shop.example.com shop.example.com theme=dark stored; sent to example.com and every name under it blog.example.com theme=light replaced the old value; sent to example.com and every name under it shop.example.com __Host-cart=FAKE-c9 stored; sent to only shop.example.com blog.example.com __Host-cart=FAKE-x ignored: a __Host- cookie needs Secure, Path=/ and no Domain shop.example.com promo=1 ignored: Domain=com is a public suffix Cookie header of each request: 1. https://shop.example.com/cart sid=FAKE-s1; theme=light; __Host-cart=FAKE-c9 2. https://shop.example.com:8443/cart sid=FAKE-s1; theme=light; __Host-cart=FAKE-c9 3. http://shop.example.com/cart theme=light 4. https://api.example.com/ theme=light
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cookie_scope.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Four results matter for security:
- Request 2: the cookies of
https://shop.example.comgo to port 8443 too. RFC 6265 says plainly that cookies do not isolate ports, so another service on another port of the same host receives them. - Request 3:
themehas noSecureattribute, so it also goes tohttp://shop.example.com. A cookie withoutSecureis not isolated by scheme. blog.example.comreplaced shop’sthemecookie, because both hosts may set cookies forexample.com. RFC 6265 warns that sibling domains get no integrity guarantee from each other.- The
__Host-cookie could not be overwritten that way. RFC 6265bis makes a__Host-cookie host-only,SecureandPath=/, and the browser refuses one with aDomainattribute.
Domain=com was refused for another reason: com is a public suffix, and browsers refuse a cookie for a public suffix,
so one site cannot set cookies for every site under it. Press Edit, add Domain=example.com to the first header and
run it again: sid then reaches api.example.com as well, which is why a session cookie is better left host-only.
A session cookie that keeps to its host
Name it with the __Host- prefix and set Secure, HttpOnly, Path=/ and SameSite=Lax without Domain. It
then reaches only the host that set it, over https, and no sibling subdomain can replace it.
Checkpoint
Check: How cookies are scoped
The browser protects its user, not your server
Put the pieces together and one rule follows. The browser’s rules protect the person using it: from other sites reading their data, and from pages writing headers they should not. None of them binds your server’s other clients, and none of them runs inside your server. Step along the path of a request through a small application, one trust boundary: A place where data passes between parts that trust each other differently, such as from the internet into your app or from your app to a third party. Data that crosses one needs a check: authentication, validation, encoding or encryption. More in “Threat modelling a feature in 30 minutes” at a time:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Many arrows. A request crosses several places where the data's level of trust changes. Each arrow is one of them, and each needs a check that the place it reaches can rely on.
- From browsers. Requests from browsers carry whatever their pages and their users made them send. The browser protects its user from other sites; it makes no promise to your server.
- From any client. Requests from any other client look exactly the same, because HTTP is text. So every check that matters runs after this point, never in the page.
- Into your app. An edge or proxy in front of your app may add headers, such as the client's address. Trust such a header only when your own edge sets it and removes any copy the client sent.
- The decision. Your app decides who is asking and what they may do, on every request, from a session it issued. Here access control, input validation and rate limits live, because here nobody else can skip them.
- Into the database. The app turns the request into queries. Parameters keep data out of the query's code, and a database account that can do only what the app needs limits what any mistake can reach.
- To and from a third party. Answers from a payment provider's API are input too. Check what comes back before acting on it, the same way you check what comes in from a browser.
- The page's checks. Checks in the page's JavaScript, such as a hidden button or a disabled field, help honest users. They are a courtesy, not a control, because no request has to come from that page.
The last step is the one this program tests. An admin page hides its export button from anyone who is not an admin, and its server either relies on that or checks for itself:
// The admin page hides its "Export all orders" button from anyone who is not an admin. That helps honest users;
// it stops nobody else, because any HTTP client can send the request the button would have sent.
const sessions = {
'FAKE-s-asha': { user: 'asha', role: 'admin' },
'FAKE-s-ravi': { user: 'ravi', role: 'customer' },
};
// What the page's script decides: whether to draw the button.
const pageShowsButton = (cookie) => sessions[cookie]?.role === 'admin';
// Weak: the server trusts that only admins ever see the button, so it never checks.
function exportWeak(request) {
return { status: 200, body: 'all orders' };
}
// Fixed: the server checks the session on every request, whoever sends it.
function exportFixed(request) {
const session = sessions[request.cookie];
if (!session) return { status: 401, body: 'sign in first' };
if (session.role !== 'admin') return { status: 403, body: 'admins only' };
return { status: 200, body: 'all orders' };
}
const requests = [
{ who: 'asha clicks the button', cookie: 'FAKE-s-asha', allowed: true },
{ who: 'ravi sends it by hand', cookie: 'FAKE-s-ravi', allowed: false },
{ who: "a script sends ravi's cookie", cookie: 'FAKE-s-ravi', allowed: false },
{ who: 'a script sends no cookie', cookie: undefined, allowed: false },
];
const verdict = (response, allowed) => `${response.status} ${(response.status === 200) === allowed ? 'right' : 'WRONG'}`;
console.log('request'.padEnd(30) + 'button?'.padEnd(9) + 'weak'.padEnd(11) + 'fixed');
for (const r of requests) {
const request = { method: 'POST', path: '/admin/export', cookie: r.cookie };
console.log(r.who.padEnd(30) + (pageShowsButton(r.cookie) ? 'shown' : 'hidden').padEnd(9) + verdict(exportWeak(request), r.allowed).padEnd(11) + verdict(exportFixed(request), r.allowed));
} Output
request button? weak fixed asha clicks the button shown 200 right 200 right ravi sends it by hand hidden 200 WRONG 403 right a script sends ravi's cookie hidden 200 WRONG 403 right a script sends no cookie hidden 200 WRONG 401 right
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hidden_button.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak server answered 200 to all four requests, three of them wrongly. The fixed one refused ravi with 403 and the script without a cookie with 401. OWASP describes this case in A01:2025 Broken Access Control, access control that lives only in front-end JavaScript, and ASVS 5.0.0 requirement 8.3.1 asks for authorisation at a trusted service layer, not in code an untrusted consumer can change.
Key idea
Every check that decides what a request may do runs on the server, on every request; checks in the page are a convenience for honest users.
Checkpoint
Check: Checks belong on the server
Practise: same origin, same site or cross site
Write the classification that the origin table showed, for origins a URL parser has already split up. The sample tests include the trailing dot, a hosting suffix and two names for one machine:
Exercise · Easy · JavaScript, Python
Same origin, same site or cross site
Write relation(a, b) in origins.mjs (Python: relation(a, b) in origins.py). Each argument is an origin that a URL parser has already split up: { scheme, host, port } in JavaScript, a tuple (scheme, host, port) in Python. port is null (None) when the URL gave none. Return 'same-origin', 'same-site' or 'cross-site':
- Same origin: the scheme, the host and the port are all equal. Hosts ignore case. A missing port means the scheme's default port, 80 for
httpand 443 forhttps. - Same site: not the same origin, but the scheme is equal and so is the site's host: the registrable domain, or the host itself when it has none (an IPv4 address, or a host that is itself a public suffix).
- Cross site: anything else.
The registrable domain is the longest suffix of the host that is in PUBLIC_SUFFIXES, plus one more label. If no suffix of the host is in the list, the last label alone counts as the public suffix. A trailing dot stays part of the host: example.com. is a different host from example.com, and its registrable domain is example.com., so shop.example.com. and api.example.com. are the same site as each other.
JavaScript · Starter code · origins.mjs
/** A sample of the Public Suffix List. pages.test stands for a hosting service whose users each get a subdomain. */
export const PUBLIC_SUFFIXES = new Set(['com', 'org', 'test', 'pages.test']);
/** 'same-origin', 'same-site' or 'cross-site' for two origins given as { scheme, host, port }. */
export function relation(a, b) {
// Starter: compares the hosts only, and takes the last two labels as the site.
if (a.host === b.host) return 'same-origin';
const lastTwo = (host) => host.split('.').slice(-2).join('.');
return lastTwo(a.host) === lastTwo(b.host) ? 'same-site' : 'cross-site';
} The sample tests · origins.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { relation } from './origins.mjs';
const o = (scheme, host, port = null) => ({ scheme, host, port });
const shop = o('https', 'shop.example.com');
function expect(a, b, want) {
assert.equal(relation(a, b), want, `${a.scheme}://${a.host}:${a.port} and ${b.scheme}://${b.host}:${b.port} are ${want}`);
assert.equal(relation(b, a), want, 'the relation is the same in both directions');
}
test('the same origin, with case and default ports filled in', () => {
expect(shop, o('https', 'shop.example.com'), 'same-origin');
expect(shop, o('https', 'SHOP.EXAMPLE.COM', 443), 'same-origin');
expect(o('http', 'shop.example.com', 80), o('http', 'shop.example.com'), 'same-origin');
});
test('same site: another port, a sibling or the parent', () => {
expect(shop, o('https', 'shop.example.com', 8443), 'same-site');
expect(shop, o('https', 'api.example.com'), 'same-site');
expect(shop, o('https', 'example.com'), 'same-site');
expect(o('https', '192.0.2.10'), o('https', '192.0.2.10', 8443), 'same-site');
expect(o('https', 'shop.example.com.'), o('https', 'api.example.com.'), 'same-site');
});
test('cross site: another scheme, another registrable domain, a trailing dot', () => {
expect(shop, o('http', 'shop.example.com'), 'cross-site');
expect(shop, o('https', 'shop.example.org'), 'cross-site');
expect(shop, o('https', 'shop.example.com.'), 'cross-site');
});
test('cross site: users of one hosting suffix, and two names for one machine', () => {
expect(o('https', 'alice.pages.test'), o('https', 'bob.pages.test'), 'cross-site');
expect(o('http', 'localhost', 3000), o('http', '127.0.0.1', 3000), 'cross-site');
expect(o('http', '127.0.0.1', 3000), o('http', '127.1.0.1', 3000), 'cross-site');
expect(o('https', '192.0.2.10'), o('https', '192.0.2.11'), 'cross-site');
}); Python · Starter code · origins.py
# A sample of the Public Suffix List. pages.test stands for a hosting service whose users each get a subdomain.
PUBLIC_SUFFIXES = {"com", "org", "test", "pages.test"}
def relation(a, b):
"""'same-origin', 'same-site' or 'cross-site' for two origins given as (scheme, host, port) tuples."""
# Starter: compares the hosts only, and takes the last two labels as the site.
if a[1] == b[1]:
return "same-origin"
last_two = lambda host: ".".join(host.split(".")[-2:])
return "same-site" if last_two(a[1]) == last_two(b[1]) else "cross-site" The sample tests · test_origins.py
from origins import relation
SHOP = ("https", "shop.example.com", None)
def expect(a, b, want):
assert relation(a, b) == want, f"{a} and {b} are {want}"
assert relation(b, a) == want, "the relation is the same in both directions"
def test_same_origin_with_case_and_default_ports():
expect(SHOP, ("https", "shop.example.com", None), "same-origin")
expect(SHOP, ("https", "SHOP.EXAMPLE.COM", 443), "same-origin")
expect(("http", "shop.example.com", 80), ("http", "shop.example.com", None), "same-origin")
def test_same_site_another_port_a_sibling_or_the_parent():
expect(SHOP, ("https", "shop.example.com", 8443), "same-site")
expect(SHOP, ("https", "api.example.com", None), "same-site")
expect(SHOP, ("https", "example.com", None), "same-site")
expect(("https", "192.0.2.10", None), ("https", "192.0.2.10", 8443), "same-site")
expect(("https", "shop.example.com.", None), ("https", "api.example.com.", None), "same-site")
def test_cross_site_scheme_registrable_domain_trailing_dot():
expect(SHOP, ("http", "shop.example.com", None), "cross-site")
expect(SHOP, ("https", "shop.example.org", None), "cross-site")
expect(SHOP, ("https", "shop.example.com.", None), "cross-site")
def test_cross_site_hosting_suffix_and_two_names_for_one_machine():
expect(("https", "alice.pages.test", None), ("https", "bob.pages.test", None), "cross-site")
expect(("http", "localhost", 3000), ("http", "127.0.0.1", 3000), "cross-site")
expect(("http", "127.0.0.1", 3000), ("http", "127.1.0.1", 3000), "cross-site")
expect(("https", "192.0.2.10", None), ("https", "192.0.2.11", None), "cross-site") A hint
Compare origins first: lower-case both hosts and fill in the default port, then check all three parts. For the site, split the host into labels and try the suffixes from the longest: for alice.pages.test try alice.pages.test, then pages.test, then test. The first one in the list is the public suffix, so the registrable domain is that suffix with the label before it, alice.pages.test here, not pages.test.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
After the exercise, the questions below start where your answers are weakest and work upwards from there:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the difference between same-origin and same-site?
Two URLs are the same origin when the scheme, the host and the port all match: https://shop.example.com and
https://shop.example.com:8443 are not. They are the same site when the scheme and the registrable domain match, the
host’s public suffix plus one label, so https://shop.example.com and https://api.example.com are the same site but
different origins. The same-origin policy, CORS and most browser isolation work by origin. SameSite cookies work by
site, so a request from a sibling subdomain counts as same-site and carries SameSite=Strict cookies, and
Sec-Fetch-Site reports it as same-site, not same-origin. Because sites are coarser and depend on the public
suffix list, security decisions should use origins where they can.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A teammate's API skips the session check for requests whose Origin header is https://admin.shop.example, because "only our admin console can send that". What is wrong with this, and how would you redesign the check?
-
Mid to senior
Your app runs at app.example.com, and each customer can publish a status page at their-name.example.com from templates they edit. The session cookie is set with Domain=example.com. What can go wrong, and what do you change?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which request headers can a page's script not set, and why does that not make them trustworthy?
Forbidden request headers include Host, Origin, Cookie, Content-Length, the Sec-Fetch headers and any name starting with Sec- or Proxy-. A browser stops page script from setting them, but a script outside a browser writes them freely, so they are signals from honest browsers, never proof of who is calling.
-
What is an origin, and what is a site?
An origin is the scheme, host and port together; the path, the query and any user name play no part, and when no port is written the scheme's default applies (443 for https). A site is coarser: the scheme with the registrable domain, which is the public suffix plus one more label. A site depends on the public suffix list, so use origins for security decisions where you can.
-
How do https://shop.example.com:8443 and http://shop.example.com compare with the page https://shop.example.com?
The first has another port, so it is a different origin but the same site. The second has another scheme, which counts for both, so it is cross site. A sibling host such as api.example.com is the same site and a different origin.
-
What does the same-origin policy block, and what does it leave alone?
It blocks reading: script from one origin cannot read a response from another unless that origin allows it with CORS. Writes (links, redirects, form posts) and embeds (images, scripts, frames) are typically allowed, so the request is still sent and the server must decide whether to act on it.
-
When is a cookie sent on a request from another site under SameSite=Strict, Lax and None?
Strict: never. Lax: only on top-level navigations with a safe method, such as following a link. None (which needs Secure): on every request, unless the browser blocks third-party cookies; Firefox and Safari restrict them by default and Chrome does not, so a server cannot count on either.
-
What do cookie scoping rules ignore, and what follows from it?
Ports and, without Secure, the scheme. A cookie of https://shop.example.com also goes to port 8443 of that host, one without Secure also goes to http, and sibling hosts under example.com can replace each other's Domain cookies. A __Host- cookie is host-only, Secure and Path=/, so a sibling cannot overwrite it.
-
How do you set a session cookie that reaches only the host that set it?
Name it with the __Host- prefix and set Secure, HttpOnly, Path=/ and SameSite=Lax without Domain. It then travels only to that host over https, and no sibling subdomain can replace it.
-
Why must every access check run on the server, even when the page hides the button?
The browser's rules protect the person using the browser, not your server's other clients. In the lesson's sample a script with no cookie got 200 from the weak server for an export that the page had hidden. OWASP A01:2025 names access control that lives only in front-end code, and ASVS 5.0.0 requirement 8.3.1 asks for authorisation at a trusted service layer.
Key takeaways
- A request is text: page script cannot set forbidden headers such as
OriginandCookie, but any client outside a browser can, so headers are signals from honest browsers, never proof. - An origin is the scheme, the host and the port; a site is the scheme and the registrable domain, which depends on the public suffix list.
- The same-origin policy stops another origin’s script from reading responses, not requests from being sent, and
SameSitedecides when a cross-site request carries a cookie. - Cookies are scoped by domain and path: they reach other ports of the same host, cookies without
Securereachhttp, and sibling subdomains can replace each other’s domain cookies unless a__Host-cookie is used. - Every decision about what a request may do is made on the server, on every request.
Terms in this lesson
- forbidden request header
- A request header that browsers never let page script set or change, such as Host, Origin, Cookie and every name that starts with Sec- or Proxy- (the Fetch standard). Clients outside a browser can send any of them.
- host-only cookie
- A cookie set without a Domain attribute. The browser sends it back only to the exact host that set it, not to its parent domain or to sibling subdomains.
- origin
- The scheme, host and port of a URL, such as https://shop.example.com with the default port 443. Two URLs share an origin only when all three match; the path, the query and any user name are not part of it.
- public suffix
- A name under which separate parties register their own names, such as com, or a hosting name such as github.io whose users each get a subdomain. The Public Suffix List records them, and browsers refuse cookies set for one.
- registrable domain
- The part of a host one label longer than its public suffix, such as example.com for shop.example.com. Two hosts with the same registrable domain and the same scheme are the same site.
- same site
- Two origins are the same site when their schemes and their registrable domains match: https://shop.example.com and https://api.example.com are the same site but different origins. Ports do not count.
- same-origin policy
- The browser rule that a page's script may read responses and documents only from its own origin, unless the other origin allows it with CORS. It limits reading; requests to other origins are still sent.
References
- Same-origin policy (MDN Web Docs (Mozilla))
- RFC 6454: The Web Origin Concept (IETF (RFC Editor))
- HTML Standard: origins and sites (WHATWG)
- URL Standard: hosts, public suffixes and registrable domains (WHATWG)
- Fetch Standard: forbidden request-headers and the Origin header (WHATWG)
- Public Suffix List (Mozilla Foundation (Public Suffix List))
- RFC 6265: HTTP State Management Mechanism (IETF (RFC Editor))
- Cookies: HTTP State Management Mechanism (draft-ietf-httpbis-rfc6265bis) (IETF HTTP Working Group)
- Set-Cookie header (MDN Web Docs (Mozilla))
- Sec-Fetch-Site header (MDN Web Docs (Mozilla))
- Third-party cookies (MDN Web Docs (Mozilla))
- A01:2025 Broken Access Control (OWASP Foundation)
- Application Security Verification Standard 5.0.0: V8 Authorization (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress