Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Who controls each line of a request and a response Python · raw/raw_http_reader.py
"""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

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:

Interactive · message by message One request, two senders Step through the shop's order request as the browser sends it, then as a script sends the same bytes.
One request, two senders, step 5 of 5. Messages so far: Browser to shop.example: POST /api/orders; shop.example to Browser: 201 Created; Any HTTP client to shop.example: the same POST.Browseradds Origin and Cookieshop.exampleAny HTTP clienta script on a laptopPOST /api/orders201 Createdthe same POSTsame bytes, two senders

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Which of these request headers can a page's script never set or change? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Cookie
    • Sec-Fetch-Site
    • Origin

    The Fetch standard forbids page script to set names such as Host, Origin, Cookie, Content-Length and every name that starts with Sec- or Proxy-. The rule binds browsers: a client outside a browser writes all of them as text.

  2. Question 2 of 2 Your API refuses every request whose Origin header is not https://shop.example, and checks nothing else. What does that stop?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Requests that honest browsers send for pages on other origins

    Origin is what an honest browser reports on its user's behalf. Checking it protects your users from pages on other sites, and it says nothing about scripts outside a browser. The decision about what a request may do still needs a session your server issued and checks.

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:

Thirteen pairs: same origin, same site or cross site JavaScript · origins/origin_site_table.mjs
// 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

  1. Question 1 of 2 How are https://shop.example.com and https://shop.example.com:8443 related?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Same site, but different origins

    The origin is the scheme, the host and the port, so a different port is a different origin. The site is the scheme and the registrable domain, here https and example.com for both, so they are the same site.

  2. Question 2 of 2 In the lesson's sample suffix list, pages.test is a public suffix. How are https://alice.pages.test and https://alice.pages.test:8080 related?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Same site, but different origins

    With pages.test a public suffix, alice.pages.test is a registrable domain of its own. Both URLs have it, with https, so they are the same site; the different port makes them different origins.

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

  1. Question 1 of 2 A page on https://other.example calls fetch() for https://shop.example/api/status, and the response carries no Access-Control-Allow-Origin header. What happened?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The request reached shop.example and was answered, and the browser kept the answer from the page's script

    The same-origin policy decides what script may read. The request was sent and answered; the browser withheld the answer because shop.example did not allow other.example with CORS. That is why a request to change something must be judged by the server, whatever page sent it.

  2. Question 2 of 2 A session cookie is set with SameSite=Lax. Which request from a page on another site carries it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Following a link to shop.example

    MDN describes Lax as sending the cookie on cross-site requests only when they are top-level navigations with a safe method, such as following a link. Images, fetch() calls and form posts from another site go without it.

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:

Where six Set-Cookie headers send their cookies Python · cookies/cookie_scope.py
"""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

Four results matter for security:

  • Request 2: the cookies of https://shop.example.com go 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: theme has no Secure attribute, so it also goes to http://shop.example.com. A cookie without Secure is not isolated by scheme.
  • blog.example.com replaced shop’s theme cookie, because both hosts may set cookies for example.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, Secure and Path=/, and the browser refuses one with a Domain attribute.

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

  1. Question 1 of 2 https://shop.example.com sets "sid=FAKE-s1; Secure; Path=/" with no Domain attribute. Which request carries the cookie?
    Show the answer: Question 1 of 2

    Answer: https://shop.example.com:8443/cart

    With no Domain the cookie is host-only: shop.example.com and nothing else. Secure limits it to https. No rule mentions the port, so request 2 of the program, port 8443, received every cookie that request 1 did.

  2. Question 2 of 2 blog.example.com is run by another team. Which session cookie of shop.example.com can it not replace?
    Show the answer: Question 2 of 2

    Answer: __Host-sid, set by shop.example.com with Secure and Path=/

    RFC 6265 gives sibling domains no integrity guarantee: a cookie with Domain=example.com can be set by any host under example.com. The __Host- prefix of RFC 6265bis closes this: host-only, Secure, Path=/, no Domain.

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:

Interactive · step by step Where data changes trust, and the check each place needs Step along the path of one request. Each step lights one arrow and names the check that belongs there.
Where data changes trust, and the check each place needs, step 8 of 8. Showing A user's browser, Any HTTP client, Edge or proxy, Your app (authorises every request), Database, Payment provider API. In focus: A user's browser.A user's browserAny HTTP clientEdge or proxyYour appauthorises every requestDatabasePayment provider API

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

A hidden button, a weak server and a fixed one JavaScript · server/hidden_button.mjs
// 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

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

  1. Question 1 of 2 In the hidden-button program, how many of the four requests did the weak server answer wrongly?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 3 requests

    The weak server answered 200 to all four. Only asha, the admin, should have got the export, so three answers were wrong: ravi's request sent by hand, the script with ravi's cookie and the script with no cookie. The fixed server answered 403, 403 and 401.

  2. Question 2 of 2 A form disables its discount field for anyone who is not staff. Where must the rule "only staff may set a discount" be enforced?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In the server's handler for the form, on every request

    The server is the one place every request passes, so the rule lives there, checked against a session the server issued. The disabled field stays, for the honest user it helps.

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 http and 443 for https.
  • 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.

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.

HTTP Header Checker Paste the headers of a response you copied from your own browser's network panel and read each one explained.

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.

  1. Question 1 of 13 Who sets the Host header of a request that a page sends with fetch()?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The browser, from the address the page asked for

    Host is on the Fetch standard's list of forbidden request headers, so a page's script cannot set it, and the browser fills it in from the address. A client outside a browser writes it like any other line.

  2. Question 2 of 13 A teammate proposes letting a request change data whenever Sec-Fetch-Site is same-origin, without looking at the session. What is wrong with that?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Any client outside a browser can send same-origin, so the header cannot replace the session check

    Sec-Fetch-Site is a good extra signal: an honest browser marks requests that came from other sites. It cannot say who is calling, so the decision still rests on a session the server issued and checks on every request.

  3. Question 3 of 13 Which parts of a URL make up its origin? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 3 of 13

    Answer:

    • The host
    • The port
    • The scheme

    An origin is the scheme, the host and the port (RFC 6454 and the HTML standard). Paths, queries and user names are not part of it.

  4. Question 4 of 13 Are https://example.com and https://example.com. (with a trailing dot) the same site?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: No, they are cross site, because the trailing dot makes a different host

    The HTML standard's own table lists https example.com and https example.com. as neither schemelessly same site nor same site. A trailing dot is a different host, with a different registrable domain.

  5. Question 5 of 13 Two student projects are published at https://asha.github.io and https://ravi.github.io. Can a cookie set by one of them with Domain=github.io reach the other?
    Show the answer: Question 5 of 13

    Answer: No, because github.io is a public suffix, so the browser refuses a cookie for it

    github.io is on the Public Suffix List, as the URL standard's own examples show, so asha.github.io and ravi.github.io are separate registrable domains and separate sites. MDN notes that a cookie's Domain cannot be a public suffix such as github.io.

  6. Question 6 of 13 What does the same-origin policy block?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Script of one origin reading responses or documents of another, unless that origin allows it with CORS

    MDN describes cross-origin writes and embedding as typically allowed and cross-origin reads as typically disallowed. The request still goes out; the browser decides whether script may read the answer.

  7. Question 7 of 13 A page on another site includes an image from https://shop.example. What does the same-origin policy allow?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: The browser loads and shows the image, but the page's script cannot read the image's data

    The image is embedded, which the policy typically allows. Reading its contents in script is a cross-origin read, which needs the other origin to allow it with CORS.

  8. Question 8 of 13 Which SameSite value keeps a cookie off even a link followed from another site?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Strict

    Strict keeps the cookie to requests that start on its own site; Lax allows top-level navigations with safe methods; None allows every cross-site request where the browser permits third-party cookies.

  9. Question 9 of 13 What is a host-only cookie?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: A cookie set without a Domain attribute, sent back only to the host that set it

    Without Domain, the browser stores the cookie with the host-only flag and returns it only to that exact host. With Domain=example.com it goes to example.com and every name under it.

  10. Question 10 of 13 In the program, why did the theme cookie reach http://shop.example.com but sid did not?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: theme has no Secure attribute, and sid does

    Cookies are not isolated by scheme unless Secure says so. A cookie without Secure travels over http too, where anyone on the network path can read it.

  11. Question 11 of 13 Your team wants to run a third-party analytics dashboard on port 8080 of app.example.com, where the app's session cookie lives. What is the risk, and the fix?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: The dashboard receives the app's cookies, because cookies ignore ports; run it on a host of its own

    Ports play no part in cookie rules, so everything on app.example.com receives the app's cookies. RFC 6265 advises against running mutually distrusting services on different ports of one host. Give the dashboard its own host.

  12. Question 12 of 13 A hidden button or a disabled field in a page is

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: a convenience for honest users, not a control

    What a page shows or hides helps people use it. The decision about what a request may do is made on the server, on every request.

  13. Question 13 of 13 Which ASVS 5.0.0 requirement asks that authorisation be enforced at a trusted service layer, not in controls an untrusted consumer can change?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: 8.3.1

    Requirement 8.3.1 of ASVS 5.0.0, at level 1, asks for authorisation at a trusted service layer. OWASP's A01:2025 describes the failure it prevents: access control that lives only in front-end JavaScript.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 Origin and Cookie, 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 SameSite decides 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 Secure reach http, 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.