The browser security model: origins, CORS, cookies, CSP and headers
What does the browser actually enforce for me, and which headers close the gaps it leaves open?
The same-origin policy stops one origin from reading another's responses and DOM, but not from sending it requests with cookies attached. CORS, cookie attributes, CSP, Fetch Metadata and the cross-origin headers each relax or tighten one specific part of that boundary.
THE MENTAL MODEL
Separate three questions: can another site make the browser send a request to me (almost always yes), will my cookies ride along (SameSite decides), and can that site read the answer (the same-origin policy says no unless CORS says yes). CORS only ever loosens reads; it never stops a request from reaching your server, so it is not a CSRF defense. Everything else is about code running inside your origin: once an attacker's script executes there, the same-origin policy protects them, not you, which is why CSP, Trusted Types and HttpOnly exist. Isolation headers (frame-ancestors, COOP, CORP, COEP, Fetch Metadata) shrink the remaining cross-origin surface: framing, window references, embedding and side channels. Each control answers one threat; none of them substitutes for another.
HOW IT FITS TOGETHER
| Stops | Does not stop | Enforced by | |
|---|---|---|---|
| Same-origin policy | Reading cross-origin responses, DOM and storage | Sending cross-site requests with cookies (CSRF) | Browser, always on |
| CORS | Nothing; it selectively relaxes the read restriction | CSRF, server-to-server calls, curl; simple requests still execute | Browser, from server response headers |
| SameSite=Lax/Strict | Cookies on cross-site subresource requests and POSTs | Same-site attacks from a sibling subdomain; XSS | Browser, per cookie |
| HttpOnly + __Host- | Script reading the cookie; subdomains overwriting it | XSS acting as the user with the cookie attached | Browser, per cookie |
| Strict CSP (nonces) | Injected and inline scripts without the per-response nonce | DOM XSS through sinks fed by trusted scripts | Browser, from response header |
| Trusted Types | Strings reaching innerHTML, eval and script.src sinks | Bugs inside your own policy's sanitizer | Browser, via CSP directive |
| HSTS | SSL stripping and http:// downgrades after first visit | The very first visit unless the domain is preloaded | Browser, cached per host |
| frame-ancestors | Clickjacking by framing your page | Your page opening or framing others | Browser, CSP directive |
| COOP same-origin | Cross-origin popups keeping a window reference to you | Requests or embeds; that is CORP and COEP | Browser, per top-level document |
| CORP / COEP | Other origins embedding your resources (CORP) | Requests to your endpoints; it only governs loading and embedding | Browser, per response / per document |
| Fetch Metadata policy | CSRF, XSSI and cross-site leaks, before handlers run | Same-site attackers; old browsers without the headers | Your server, from Sec-Fetch-* headers |
- app.example.com JS → Browserfetch PUT, JSON, credentials: includeapplication/json and the PUT method make this non-simple, so a preflight is required.
- Browser → api.example.comOPTIONS /orders + OriginAccess-Control-Request-Method: PUT and Access-Control-Request-Headers: content-type. No cookies, no Authorization.
- api.example.com → Browser204 + Allow-Origin, Methods, HeadersExact origin, Access-Control-Allow-Credentials: true, Max-Age: 600, Vary: Origin. A wildcard is rejected for credentialed requests.
- Browser → BrowserCheck headers, cache preflight resultBrowsers cap Max-Age (Chromium at 2 hours, Firefox at 24). If this check fails the PUT is never sent and the page sees a TypeError.
- Browser → api.example.comPUT /orders + cookies + OriginSameSite still applies: api is same-site with app, so Lax cookies are sent.
- api.example.com → Browser200 + Allow-Origin + Allow-CredentialsThe handler has already run. CORS only decides whether the response is exposed.
- Browser → app.example.com JSResponse exposed to page codeOnly CORS-safelisted response headers are readable unless listed in Access-Control-Expose-Headers.
KEY TERMS
- Origin vs site
- An origin is scheme + host + port (https://app.example.com:443). A site is scheme + registrable domain (https://example.com), using the Public Suffix List. The same-origin policy and CORS work on origins; SameSite cookies and Sec-Fetch-Site: same-site work on sites, so a compromised sibling subdomain is same-site but cross-origin.
- Same-origin policy
- Scripts may send requests anywhere a form or img could, but may only read responses, DOM, and storage from their own origin. Writes leak (CSRF); reads do not. Embedding (script, img, iframe) is allowed by default, which is why XSSI, clickjacking and side channels need separate controls.
- CORS
- A server opt-in that lets named origins read its responses. Non-simple requests (methods other than GET/HEAD/POST, non-safelisted headers, JSON content type) trigger a credential-less OPTIONS preflight first. Simple requests are sent without asking, so state changes must never depend on CORS for protection.
- Cookie attributes and prefixes
- Secure (HTTPS only), HttpOnly (no document.cookie), SameSite=Lax or Strict (not sent on cross-site subresource requests or POSTs; Chromium treats a missing SameSite as Lax, other engines do not, so always set it). __Host- forces Secure, Path=/ and no Domain, so subdomains cannot plant or overwrite it. Chrome kept third-party cookies under user choice rather than removing them (April 2025); Safari blocks them and Firefox partitions them, so cross-site embeds should use Partitioned (CHIPS) or the Storage Access API.
- Strict CSP and Trusted Types
- A strict policy allows scripts by per-response nonce or hash, not by host allowlist: script-src 'nonce-R' 'strict-dynamic'; object-src 'none'; base-uri 'none'. 'strict-dynamic' lets trusted scripts load their own dependencies. Roll out with Content-Security-Policy-Report-Only first. require-trusted-types-for 'script' then makes DOM sinks such as innerHTML reject plain strings; Trusted Types became Baseline in 2026.
- Fetch Metadata
- Browsers send Sec-Fetch-Site (same-origin, same-site, cross-site, none), Sec-Fetch-Mode and Sec-Fetch-Dest on requests to HTTPS origins. A resource isolation policy rejects cross-site, non-navigation requests before routing. OWASP accepts Sec-Fetch-Site as the primary CSRF signal for apps targeting modern browsers, with an Origin check as a mandatory fallback when the headers are absent; framework CSRF tokens remain its default.
- Transport and isolation headers
- HSTS pins HTTPS for a host (max-age, includeSubDomains, optional preload list). X-Content-Type-Options: nosniff stops MIME sniffing into script. frame-ancestors replaces X-Frame-Options. COOP same-origin severs window references from cross-origin openers; COEP require-corp or credentialless plus COOP make a page crossOriginIsolated (SharedArrayBuffer, precise timers); CORP declares who may embed a resource.
IN YOUR STACK
TypeScript · Next.js 16 proxy.ts Server
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"report-to csp",
].join("; ");
// Next.js reads the nonce from the request CSP header and stamps its own scripts.
const headers = new Headers(request.headers);
headers.set("x-nonce", nonce);
headers.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers } });
const name = process.env.CSP_ENFORCE ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only";
response.headers.set(name, csp);
response.headers.set("Reporting-Endpoints", 'csp="/api/csp-reports"');
return response;
}- Next.js 16 renamed middleware.ts to proxy.ts. Nonces force dynamic rendering: static pages, ISR and Partial Prerendering cannot carry a per-request nonce; the experimental SRI option is the hash-based alternative.
- Read the nonce in Server Components with (await headers()).get("x-nonce") and pass it to next/script for third-party tags.
- Development needs 'unsafe-eval' for React's error overlays; never ship it.
TypeScript · Express + helmet 8 Server
import { randomBytes } from "node:crypto";
import express, { type Response } from "express";
import helmet from "helmet";
const app = express();
app.use((req, res, next) => {
res.locals.cspNonce = randomBytes(16).toString("base64"); // 128 bits per response
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: { // merged over helmet defaults (default-src 'self', object-src 'none', ...)
scriptSrc: [(req, res) => `'nonce-${(res as Response).locals.cspNonce}'`, "'strict-dynamic'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
},
},
strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true, preload: true },
crossOriginOpenerPolicy: { policy: "same-origin" },
crossOriginResourcePolicy: { policy: "same-site" },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
}));
// Also set by default: X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN,
// Origin-Agent-Cluster, X-XSS-Protection: 0; X-Powered-By is removed.- Render the nonce into every script tag: <script nonce="<%= cspNonce %>">. A nonce that is static or cached in HTML is worthless.
- Only add preload after every subdomain serves HTTPS; removal from browser preload lists takes months.
- COEP is off by default in helmet. Enable require-corp or credentialless only when you need cross-origin isolation, after checking that every embedded resource cooperates.
TypeScript · Express middleware Server
import type { RequestHandler } from "express";
const ALLOWED_SITES = new Set(["same-origin", "same-site", "none"]);
const CROSS_ORIGIN_PATHS = new Set(["/oauth/callback", "/.well-known/openid-configuration"]);
export const resourceIsolation: RequestHandler = (req, res, next) => {
res.vary("Sec-Fetch-Site").vary("Sec-Fetch-Mode").vary("Sec-Fetch-Dest");
const site = req.get("Sec-Fetch-Site");
if (!site) return originCheck(req, res, next); // old browser or non-browser client
if (ALLOWED_SITES.has(site)) return next();
const dest = req.get("Sec-Fetch-Dest") ?? "";
const isNavigation = req.get("Sec-Fetch-Mode") === "navigate" && req.method === "GET";
if (isNavigation && dest !== "object" && dest !== "embed") return next();
if (CROSS_ORIGIN_PATHS.has(req.path)) return next();
res.status(403).send("Cross-site request rejected");
};
// Mount before authentication, body parsing and routing:
// app.use(resourceIsolation);- originCheck is your fallback: compare Origin (then Referer) against an exact allowlist for unsafe methods. OWASP treats it as mandatory alongside Fetch Metadata.
- same-site lets sibling subdomains through. For state-changing API routes, accept only same-origin if no sibling needs access.
- Ship it in log-only mode first; webhooks, OAuth form_post callbacks and embeds are the usual exemptions.
TypeScript · Express 5 res.cookie Server
const base = { secure: true, httpOnly: true, path: "/" } as const;
// First-party session: host-locked, script-invisible, not sent cross-site.
res.cookie("__Host-sid", sessionId, { ...base, sameSite: "lax", maxAge: 8 * 60 * 60 * 1000 });
// Set-Cookie: __Host-sid=...; Max-Age=28800; Path=/; HttpOnly; Secure; SameSite=Lax
// Step-up or admin flows that never start from another site's link.
res.cookie("__Host-admin", adminToken, { ...base, sameSite: "strict" });
// State for a widget embedded on other sites: one jar per top-level site (CHIPS).
res.cookie("__Host-widget", widgetId, { ...base, sameSite: "none", partitioned: true });
// Logout: clear with the same name, path and attributes, then destroy server-side.
res.clearCookie("__Host-sid", { ...base, sameSite: "lax" });- Express maxAge is in milliseconds; the header value is seconds. Max-Age wins over Expires when both are present.
- Lax still sends the cookie on top-level GET navigations from other sites, so GET handlers must never change state.
- The newer __Http- and __Host-Http- prefixes additionally guarantee the cookie came from a Set-Cookie header, not script; Chrome 140+ and Firefox 143+ enforce them, Safari does not yet, and browsers without support accept prefixed cookies without enforcing the rules, so keep HttpOnly as the real control.
JavaScript · Trusted Types API Client
// Response header:
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html dompurify
// (DOMPurify tries to register its own "dompurify" policy; allow it or expect violation reports.)
import DOMPurify from "dompurify";
const htmlPolicy = window.trustedTypes?.createPolicy("app-html", {
createHTML: (input) => DOMPurify.sanitize(input),
}) ?? { createHTML: (input) => DOMPurify.sanitize(input) }; // engines without TT
export function renderComment(el, comment) {
el.querySelector(".author").textContent = comment.author; // text: no sink, no policy
el.querySelector(".body").innerHTML = htmlPolicy.createHTML(comment.html);
}
// Under enforcement this throws a TypeError instead of executing markup:
// el.innerHTML = comment.html;- The trusted-types directive allowlists policy names; createPolicy with an unlisted name throws, which keeps policies few and reviewable.
- A policy named default receives every raw string that hits a sink. Use it only as a migration shim, and log what it sees.
- Start with Content-Security-Policy-Report-Only to find sinks fed by third-party libraries before enforcing.
TypeScript · Express + cors Server
import cors from "cors";
const ALLOWED_ORIGINS = new Set(["https://app.example.com", "https://admin.example.com"]);
const apiCors = cors({
origin(origin, callback) {
// Exact match only. Never reflect the Origin header or use a suffix regex.
callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin) ? origin : false);
},
credentials: true, // Access-Control-Allow-Credentials: true
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
exposedHeaders: ["Request-Id"],
maxAge: 600, // seconds the browser may cache the preflight
});
// Before auth: preflights never carry cookies or Authorization.
app.use("/api", apiCors);- With a dynamic origin the package sets Vary: Origin, so CDNs do not serve one origin's CORS headers to another.
- A disallowed origin gets no CORS headers, but the request may still have run. Keep CSRF defenses on every state change.
- Public, cookie-less resources can use origin: "*" with credentials off; that combination is safe.
WHERE IT BITES
- Treating CORS as access control. It is enforced by browsers on reads only; curl, servers and simple cross-site form posts reach your handler regardless.
- Reflecting the request Origin into Access-Control-Allow-Origin with credentials, or matching origins with endsWith or a loose regex. Any origin, or any lookalike domain or taken-over subdomain, can then read authenticated responses.
- An allowlist CSP such as script-src 'self' https://cdn.example.com. Allowlisted hosts with JSONP endpoints or user uploads bypass it; use nonces or hashes with 'strict-dynamic'.
- Relying on SameSite alone for CSRF. Lax permits top-level GETs, same-site subdomains are trusted, and only Chromium applies Lax when the attribute is missing.
- Setting HSTS preload or includeSubDomains before every subdomain is on HTTPS, which locks out internal tools until max-age expires.
- Assuming third-party cookies are gone in Chrome, or that they work everywhere. Chrome kept them under user control, Safari blocks them, Firefox partitions them; design embeds with Partitioned cookies or the Storage Access API.
CLOSE THE AI. EXPLAIN THIS.
An attacker page posts a hidden form to your API, and your CORS config rejects its origin. Explain whether the request ran, whether your session cookie was attached, and which two controls would actually have stopped it.WHEN IT BREAKS IN PRODUCTION
SOURCES
- MDN: Cross-Origin Resource Sharing (CORS) (opens in new tab)Checked
- MDN: Set-Cookie (opens in new tab)Checked
- web.dev: Mitigate cross-site scripting with a strict CSP (opens in new tab)Checked
- web.dev: Protect your resources from web attacks with Fetch Metadata (opens in new tab)Checked
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet (opens in new tab)Checked
- Privacy Sandbox: Next steps for Privacy Sandbox and tracking protections in Chrome (opens in new tab)Checked
Explainer reviewed