The Security Model
The browser runs code from strangers next to data from your bank, and the whole platform is the set of rules that keeps those apart. This part is origin versus site and what each guards, CORS as read permission, CSP as the XSS backstop and how to roll it out, the three attacks the headers exist for, site isolation and the COOP/COEP/CORP triad, sandboxed frames and the permission model, and the HTTPS and header set that every production response should carry.
Origin and site: the two boundaries
"Same-origin, same-site, cross-origin, cross-site. Which one does the browser actually check, and for what?"
There are two boundaries, and most security confusion in web apps comes from using one where the browser uses the other. Origin is (scheme, host, port), matched exactly. Site is scheme plus the registrable domain, the part of the host directly under a public suffix. Script access, storage, and response reads are guarded by origin. Cookies, SameSite, and process placement are guarded by site.
- DOM access across frames.
iframe.contentWindow.documentthrows unless same-origin. - Reading responses. A cross-origin fetch's body is unreadable without CORS. Images and scripts can be loaded (the old web depended on it) but not inspected: canvas becomes tainted, script errors become "Script error." without crossorigin.
- Storage. localStorage, IndexedDB, Cache API, OPFS: per origin, further partitioned by top-level site.
- Permissions. Camera, geolocation, notifications: granted per origin.
- Cookies.
Domain=example.comspans subdomains. SameSite compares the request's site to the top-level site. - Process isolation. Chrome's unit is the site: cross-site frames go to other processes; same-site frames share one.
- Storage partitioning key. The top-level site, combined with the origin.
CORS: who may read what
Cross-Origin Resource Sharing is the protocol by which a server tells the browser that a different origin may read its responses. The browser enforces it; the server only declares. Two facts resolve most CORS confusion: it protects reads, not writes; and the server already processed a simple request before the browser decided whether to show you the response.
- Simple requests (GET, HEAD, POST; Content-Type of form-urlencoded, multipart, or text/plain; no custom headers) go straight out with an
Originheader. The response is readable ifAccess-Control-Allow-Originmatches. - Everything else is preflighted. An OPTIONS request with
Access-Control-Request-Methodand-Headersasks permission. The server answers withAllow-Origin,Allow-Methods,Allow-Headers, andMax-Age(cache the answer; Chrome caps at two hours). If the preflight fails, the real request is never sent. - Credentials (cookies, Authorization, client certs) are excluded unless
credentials: 'include'. Then the server must sendAllow-Credentials: trueand an exact origin, never*, and should sendVary: Originso a shared cache does not serve one origin's permission to another. - Response headers are filtered. Script sees only the CORS-safelisted ones unless
Access-Control-Expose-Headerslists more. - Opaque responses.
mode: 'no-cors'sends the request and yields a response with status 0 and nothing readable. Useful only for caching or firing a beacon.
- Reflecting the Origin header unconditionally. Equivalent to
*with credentials, which the browser would otherwise forbid. Any site can read authenticated responses. - Allowlisting by suffix regex.
/example\.com$/matchesnotexample.com. Compare exact origins from a list. - Assuming CORS stops the request. A cross-site POST with a form content type is simple: it is sent, cookies and all (subject to SameSite), and the server runs it. That is CSRF, and CORS is not the defence; SameSite cookies and tokens are.
- Preflight on every request. A custom header (
X-Requested-With, a tracing header) on every GET doubles the round trips. SetMax-Ageand consider whether the header needs to be custom.
Content Security Policy: the XSS backstop
CSP is a response header that tells the browser which sources of script, style, images, frames and connections the page is allowed to use. Its main job is to make cross-site scripting non-exploitable even when an injection exists: the attacker gets HTML into the page, but the browser refuses to run it.
script-src 'nonce-<random>' 'strict-dynamic'. Only scripts carrying this response's nonce run; scripts they create inherit permission. Host allowlists are obsolete: they are bypassable through any JSONP endpoint or open redirect on an allowed host, and they break on every CDN change.object-src 'none'. No plugins.base-uri 'none'or'self'. An injected<base>would redirect every relative script URL.frame-ancestors 'none'or a list. The replacement for X-Frame-Options; clickjacking defence.form-action 'self'. An injected form cannot post credentials elsewhere.require-trusted-types-for 'script'where you can: innerHTML and friends only accept Trusted Types objects, which forces every sink through a sanitiser. The strongest DOM XSS defence available.report-towith a Reporting-Endpoints header. Violations arrive as JSON. Noisy (extensions trigger them) but invaluable during rollout.
- Inline event handlers (
onclick="...") andjavascript:URLs never run under a nonce policy. Convert to addEventListener. This is the bulk of a migration on an older codebase. - Inline styles if you also lock
style-src. Most teams leave style-src looser; style injection is a lesser attack. - eval, new Function, string setTimeout. Some libraries need them (old template engines). Replace or isolate;
'unsafe-eval'reopens a wide hole. - Third-party tags. A tag manager that injects scripts needs
'strict-dynamic'and must itself be nonced. Its own injected scripts then run; so does anything it was configured to inject, which is a governance problem not a CSP one. - SSR nonces. The nonce must be fresh per response and reach every script tag, including those from the framework. Every major framework has a nonce prop; caches must not cache the HTML with a nonce in it (or must cache per-nonce, which defeats caching). Static hosting without a server cannot do nonces; hashes (
'sha256-...'of each inline script) are the alternative for static sites.
rollout:
week 1–2 Content-Security-Policy-Report-Only: <policy>; report-to csp
collect; group by blocked-uri and line; fix
week 3 enforce for 5% of responses
week 4 enforce for all
Report-Only is free and precise. there is no reason to go from nothing to enforce.XSS, CSRF, clickjacking: the attacks the headers are for
- Stored: attacker content saved server-side and rendered to other users. Reflected: attacker content in a URL rendered back in the response. DOM-based: the page's own script reads an attacker-controlled source (location.hash, postMessage data) and writes it to a sink (innerHTML, eval, location).
- First defence: output encoding by context. HTML body, attribute, JS string, URL, CSS: each needs its own escaping. Frameworks that escape by default (React's JSX, Vue templates) cover the HTML context;
dangerouslySetInnerHTML,href="javascript:", and template literals into innerHTML are the holes. - Second defence: CSP as above. Injection without execution.
- Third: Trusted Types, which makes the sinks refuse strings.
- Sanitising HTML when you must render user HTML: DOMPurify or the Sanitizer API; never a regex; never on the server alone (the client's parser is the one that matters).
- The attack: a page on evil.site submits a form (or triggers a simple request) to bank.example; the browser attaches bank.example's cookies; the server sees an authenticated request.
- SameSite=Lax stops cross-site POSTs carrying the cookie. Default in Chrome; not in all browsers for all cases; subdomains you do not control are same-site.
- Synchroniser token: a per-session random value in a hidden field or header, checked server-side. The classic defence; still correct.
- Origin / Sec-Fetch-Site checking: the server rejects state-changing requests whose
Sec-Fetch-Siteis cross-site. Cheap and reliable in modern browsers. - Custom header requirement: an API that only accepts JSON with a custom header is not simple, so cross-site requests preflight and fail. Only for APIs, and only if CORS is strict.
- Clickjacking: your page in an invisible iframe over a decoy; the user clicks your button thinking it is theirs.
frame-ancestorsin CSP (or X-Frame-Options: DENY) prevents the framing. - Sec-Fetch-Site / -Mode / -Dest / -User headers: the browser tells the server how a request was initiated. A resource isolation policy on the server ("images may be fetched cross-site; everything else must be same-origin or a navigation") blocks whole classes of attack in a few lines of middleware.
- Referrer-Policy: strict-origin-when-cross-origin (the default now): full URL to same-origin, origin only cross-origin, nothing on downgrade. Path-bearing referrers leaked tokens in URLs for years.
Site isolation and the isolation headers
Spectre changed what a renderer process is allowed to contain. If any code in a process can read any memory in it via timing, then cross-origin data must not share a process with untrusted code. Chrome's answer is site isolation: one process per site, enforced for all sites on desktop and for sites the user logs into on Android. The isolation headers extend that to subresources, which site isolation alone cannot cover.
- Cross-site frames go out of process. The parent holds a RemoteFrame proxy; compositing stitches the surfaces; input is routed by the browser process to the right renderer.
- Cross-Origin Read Blocking / Opaque Response Blocking. A cross-origin response that is HTML, XML or JSON requested as an image or script never reaches the renderer; the network service sniffs and drops it. Sensitive JSON cannot be pulled into an attacker's process via an img tag.
- What it cannot cover: resources you legitimately load cross-origin (fonts, scripts, images). They are in your process. That is why SharedArrayBuffer and precise timers were removed after Spectre, and why they come back only with the isolation headers.
- Cross-Origin-Opener-Policy: same-origin. Severs window references with cross-origin openers and openees. The popup your OAuth flow opens becomes unreachable via window.opener; use postMessage or redirects.
same-origin-allow-popupskeeps the popup relationship while protecting you. - Cross-Origin-Embedder-Policy: require-corp. Every cross-origin subresource must opt in via CORS or CORP, or fail.
credentialless: non-opted-in resources load without credentials instead. - Cross-Origin-Resource-Policy: same-origin | same-site | cross-origin. Set on the resource. Public CDN assets: cross-origin. Your API: same-site at most, and prefer same-origin.
- The result:
crossOriginIsolated. SharedArrayBuffer, Wasm threads, high-resolution timers,performance.measureUserAgentSpecificMemory.
Sandboxing: iframes, permissions, and the Permissions Policy
sandboxwith no value: the frame gets a unique opaque origin (no storage, no cookies, no same-origin access even to its own origin), no scripts, no forms, no popups, no top navigation, no plugins.- Re-enable selectively:
allow-scripts,allow-forms,allow-popups,allow-same-origin(restores the real origin: with allow-scripts this lets the frame remove its own sandbox if it is same-origin with you, so never combine those for untrusted content from your own origin),allow-top-navigation-by-user-activation,allow-downloads. - srcdoc for inline content with a sandbox;
cspattribute to impose a policy on the framed document (Chromium). - The pattern for untrusted HTML: a sandboxed iframe on a separate origin (user-content.example.net), with allow-scripts only if needed, communicating over postMessage with origin checks. Rendering it in your own origin's DOM, however sanitised, is the risk you are avoiding.
- Header:
Permissions-Policy: camera=(), geolocation=(self), fullscreen=(self "https://video.example"). Which powerful features this document and its frames may use. - Iframe
allowattribute: delegates specific features to a specific frame. Without it, a cross-origin frame cannot use the camera even if the user would grant it. - Use it to: stop third-party frames using sensors, autoplay, or payment APIs; turn off features your app never uses so an injected frame cannot either; and, in newer browsers, control document-level behaviours like
unloadhandlers and synchronous XHR.
- Per origin, remembered. Grants persist until revoked; Chrome auto-revokes unused permissions after months.
- Requires a user gesture for the prompt on most features; a prompt with no gesture is blocked or quietly denied.
- Query first:
navigator.permissions.query({ name })returns granted, denied, or prompt. Never prompt on page load; ask in context, after the user has done the thing that needs it, and handle denied with a path to re-enable in browser settings.
HTTPS, mixed content, HSTS, and the certificate chain
- Identity: the certificate chains to a root the OS or browser trusts, covers the hostname, is within its validity, and (Chrome, Safari) appears in Certificate Transparency logs.
- Confidentiality and integrity: TLS 1.2 or 1.3 with a modern cipher. The network course has the handshake.
- Not: that the site is honest. A phishing site has a valid certificate too.
- Blockable: http scripts, iframes, fetches, fonts, WebSockets on an https page are blocked outright.
- Upgradable: http images, audio, video are rewritten to https and fail if that fails. Chrome upgrades them automatically.
Content-Security-Policy: upgrade-insecure-requestsrewrites everything on your page to https; the fix for a legacy codebase full of http:// asset URLs.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload. After the first https visit, the browser refuses to make http requests to the host for a year and refuses to click through certificate errors. The first visit is the gap; the preload list closes it by shipping your domain in the browser.- includeSubDomains is a commitment. Every subdomain, including the forgotten dev one, must serve valid https or be unreachable.
- X-Content-Type-Options: nosniff. Stops the browser guessing a content type; a text file served as text/plain cannot be executed as script.
- Referrer-Policy, Permissions-Policy, CSP, frame-ancestors, COOP/CORP: covered above. A security headers scanner will list the same set; the point is knowing what each one is for.
Reading security in DevTools
| Where | Shows | Use it for |
|---|---|---|
| Security panel | Certificate, chain, TLS version, cipher, mixed content, per-origin breakdown | "Is this page actually secure"; which subresource broke the padlock |
| Console (CORS errors) | The exact reason: missing Allow-Origin, credentials with *, preflight method not allowed | Reading the message instead of guessing; it names the header |
| Network → a request → Headers | Origin, Sec-Fetch-*, Access-Control-*, Set-Cookie with blocked reasons | Seeing what the browser sent and what the server said; preflights appear as separate OPTIONS rows (enable the filter) |
| Console (CSP violations) | Directive, blocked URI, source file and line | What a policy would block; what it did block |
| Issues panel | Grouped security issues: cookies, CORS, CSP, mixed content, COEP | A checklist with links to the offending request |
| Application → Frames | Each frame's origin, sandbox flags, COOP/COEP state, isolation status | Confirming crossOriginIsolated; seeing why it is false |
| Application → Cookies (blocked column) | Cookies the browser refused to set or send, with reasons | SameSite, Secure, prefix violations |
| chrome://process-internals | Which sites are in which process | Verifying site isolation; seeing OOPIFs |
- Route
/security/cors-playground: a page on one port, an API on another. Toggle the API's headers (Allow-Origin, Allow-Credentials, Expose-Headers) and watch each fetch succeed or fail with the console's exact reason. - Route
/security/csp-report-only: a page with inline handlers and an injected script under Report-Only. Read the violations; flip to enforce; see what dies. - Route
/security/csrf: a cross-site form POST to a cookie-authenticated endpoint. Observe SameSite=Lax blocking the cookie in the Network Cookies tab, then set SameSite=None and watch it go through. - Route
/security/isolation: a page that readscrossOriginIsolated, with COOP/COEP toggles, a cross-origin image with and without CORP. Watch the Application → Frames panel update.