← Back to the library
SecurityHow it works · Applied · 15 min

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

Each control stops one thingRead across a row before relying on a control. The right column is where most incidents come from.
StopsDoes not stopEnforced by
Same-origin policyReading cross-origin responses, DOM and storageSending cross-site requests with cookies (CSRF)Browser, always on
CORSNothing; it selectively relaxes the read restrictionCSRF, server-to-server calls, curl; simple requests still executeBrowser, from server response headers
SameSite=Lax/StrictCookies on cross-site subresource requests and POSTsSame-site attacks from a sibling subdomain; XSSBrowser, per cookie
HttpOnly + __Host-Script reading the cookie; subdomains overwriting itXSS acting as the user with the cookie attachedBrowser, per cookie
Strict CSP (nonces)Injected and inline scripts without the per-response nonceDOM XSS through sinks fed by trusted scriptsBrowser, from response header
Trusted TypesStrings reaching innerHTML, eval and script.src sinksBugs inside your own policy's sanitizerBrowser, via CSP directive
HSTSSSL stripping and http:// downgrades after first visitThe very first visit unless the domain is preloadedBrowser, cached per host
frame-ancestorsClickjacking by framing your pageYour page opening or framing othersBrowser, CSP directive
COOP same-originCross-origin popups keeping a window reference to youRequests or embeds; that is CORP and COEPBrowser, per top-level document
CORP / COEPOther origins embedding your resources (CORP)Requests to your endpoints; it only governs loading and embeddingBrowser, per response / per document
Fetch Metadata policyCSRF, XSSI and cross-site leaks, before handlers runSame-site attackers; old browsers without the headersYour server, from Sec-Fetch-* headers
A credentialed CORS request with preflightA JSON PUT with cookies. The preflight carries no credentials; the browser decides whether to send the real request and whether page code may read its response.
  1. app.example.com JS → Browserfetch PUT, JSON, credentials: includeapplication/json and the PUT method make this non-simple, so a preflight is required.
  2. Browser → api.example.comOPTIONS /orders + OriginAccess-Control-Request-Method: PUT and Access-Control-Request-Headers: content-type. No cookies, no Authorization.
  3. 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.
  4. 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.
  5. Browser → api.example.comPUT /orders + cookies + OriginSameSite still applies: api is same-site with app, so Lax cookies are sent.
  6. api.example.com → Browser200 + Allow-Origin + Allow-CredentialsThe handler has already run. CORS only decides whether the response is exposed.
  7. 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.
Next.js 16 proxy.ts documentation (opens in new tab)

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.
Express + helmet 8 documentation (opens in new tab)

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.
Express middleware documentation (opens in new tab)

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.
Trusted Types API documentation (opens in new tab)

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.
Express + cors documentation (opens in new tab)

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.

SOURCES

Explainer reviewed