Skip to content

6.10 — The Browser Security Model

The same request, three ways:

sh
curl https://api.partner.example/v1/rates          # 200 OK, JSON printed

A request tool on a developer's machine: 200 OK, JSON shown.

js
await fetch('https://api.partner.example/v1/rates');
// TypeError: Failed to fetch
// Console: blocked by CORS policy: No 'Access-Control-Allow-Origin' header

Same URL, same method, same server. And the crucial detail that most explanations skip: the request was sent. The server received it and answered. The browser then refused to let your JavaScript read the response.

Understanding why the browser does that — and why curl does not — is the whole of this page, because the same reasoning produces every other rule in the browser's security model.

1. The origin, and the policy built on it

An origin is exactly three things: scheme, host, port. All three must match.

URLSame origin as https://shop.example/a?
https://shop.example/b/cYes — path is irrelevant
http://shop.example/aNo — different scheme
https://api.shop.example/aNo — different host, subdomains count
https://shop.example:8443/aNo — different port

The same-origin policy is the browser's foundational rule: a document from one origin may not read data from another origin. Without it, a page you opened in one tab could read your bank's page in another, take your session cookie, and read the response of any request it made on your behalf.

The distinction that explains everything else

Read that rule again carefully. It says read, not send.

The browser freely sends cross-origin requests, with cookies, and always has. That is what makes <img src="https://other.example/pic.jpg">, a <script> from a CDN, a stylesheet from a font host, and a form posting to another site all work. The web is built on cross-origin requests.

What the policy blocks is the response coming back into your JavaScript.

And this asymmetry is precisely why cross-site request forgery exists. A malicious page can make your browser send an authenticated POST to your bank — the request goes, the cookie is attached, the transfer happens — and the attacker never sees the response, which they do not need. Chapter 5.6.3 covers CSRF and its defences; the point here is that it is not a bug in the same-origin policy, it is the direct consequence of the policy being about reading.

What "cannot read" means concretely

Cross-origin resources can be used but not inspected:

  • An image renders. Draw it into a canvas and the canvas becomes taintedgetImageData() then throws, because otherwise you could read the pixels of a private image.
  • A script executes with your page's full privileges. An error inside it is reported as a bare Script error. with no message, file or line, because the message could leak the script's contents. Adding crossorigin="anonymous" plus a permissive CORS header restores real error reporting, which is why every error-monitoring guide asks for it.
  • A stylesheet applies. Enumerating its rules from JavaScript throws.
  • An iframe displays. Reaching into its contentDocument throws. The two documents can only talk through postMessage.
  • A fetch in no-cors mode returns an opaque response — status 0, no headers, no body. Useful only for putting in a cache.

2. CORS: a controlled relaxation

CORS (cross-origin resource sharing) is how a server says "this origin is allowed to read my responses". It is a relaxation of the default, not a restriction added on top — a distinction worth holding onto, because "CORS is blocking me" reads as though CORS caused the problem when in fact the default was already no.

Two facts to state up front:

CORS is enforced by the browser, and only by the browser. The server does not block anything; it states a policy and the browser obeys it. That is why curl and a desktop request tool are unaffected — they are not browsers and have no origin to protect.

CORS is therefore not access control. A permissive Access-Control-Allow-Origin does not expose an endpoint to attackers, who were never constrained by it. A restrictive one does not protect it. Authentication and authorisation are separate and mandatory (Chapter 9.9.5).

Simple requests

Some requests are sent straight away, with the browser checking the response afterwards. A request is "simple" when it uses GET, HEAD or POST, carries only a short list of safe headers, and — for POST — has a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain.

http
GET /v1/rates HTTP/1.1
Host: api.partner.example
Origin: https://shop.example            ← the browser adds this, you cannot

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.example

The browser compares the header against the page's origin. Match, and your code gets the response. No match, and the response is discarded and fetch rejects — after the server has already done the work.

The list is exactly the set of requests a plain HTML form could already make. That is the design principle: no new capability is granted without a check, and forms could always post cross-origin.

Preflight

Anything outside that set gets a check first.

browserapi.partner.example① OPTIONS /v1/ratesOrigin · Access-Control-Request-Method: PUTAccess-Control-Request-Headers: content-type, authorization② 204 No ContentAllow-Origin · Allow-Methods · Allow-Headers · Max-Age: 600③ the real PUT — only now④ 200 + Allow-Origin (required again)
The preflight asks permission before the real request happens, so a DELETE the server would refuse is never actually delivered.

What triggers a preflight:

  • Any method other than GET, HEAD or POST — so every PUT, PATCH and DELETE.
  • Content-Type: application/json — which means essentially every modern API call.
  • Any custom header: Authorization, X-Request-Id, X-CSRF-Token.

Why it exists: the real request may have side effects. A DELETE /accounts/42 that reached the server before permission was checked would have deleted the account, and refusing to show the response afterwards would be no comfort. The preflight is a safe, side-effect-free question asked first.

Step ④ matters and is missed constantly. The preflight's approval covers the preflight. The real response must carry Access-Control-Allow-Origin too, or it is blocked despite the preflight succeeding — producing the confusing case where the OPTIONS shows 204 in the network panel and the request still fails.

Access-Control-Max-Age caches the preflight result for that method and path, in seconds. Without it, every single request is doubled. Setting it to ten minutes removes a round trip from most calls, and it is one of the cheapest latency wins available on a cross-origin API.

Credentials

By default fetch does not send cookies cross-origin. With credentials: 'include', three extra rules apply at once:

http
Access-Control-Allow-Origin: https://shop.example     ← never *, must be exact
Access-Control-Allow-Credentials: true
Vary: Origin                                          ← for any cache in between

A wildcard is forbidden with credentials, and the reason is direct: * plus cookies would mean any site on the internet could read your authenticated responses. The browser rejects the combination outright.

So a server supporting several origins must echo the request's Origin back after checking it against an allow-list — and then Vary: Origin is mandatory, or a shared cache serves origin A's response, complete with A's Allow-Origin header, to origin B.

The four misconfigurations that are real vulnerabilities

Reflecting any Origin with credentials enabled. This is * with extra steps, and it is worse because it looks specific:

js
// Every attacker site is now allowed to read authenticated responses.
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);   
res.setHeader('Access-Control-Allow-Credentials', 'true');

Allowing null. Sandboxed iframes and some redirect chains send Origin: null, so an attacker can produce it deliberately. Allow-Origin: null is an open door.

A sloppy allow-list check. origin.endsWith('shop.example') matches evil-shop.example. origin.includes('shop.example') matches https://evil.com/?x=shop.example. Compare against an exact list of full origin strings.

Wildcard subdomains when subdomains are not fully trusted. If any subdomain can host user content, allowing *.shop.example hands that content your API.

Why the developer tool works and the browser does not

curl, and desktop request tools generally, do not run a page from an origin. There is no other site whose data needs protecting, so there is nothing to enforce. The endpoint was never protected by CORS; it was protected by whatever authentication it has. If it has none, it is open to everyone with a terminal, and the browser's error message was not the security boundary.

3. Cross-site scripting, and what CSP changes

XSS is attacker-controlled content executing as script in your origin. Once that happens, the same-origin policy is working perfectly and working for the attacker: their code is your origin, so it can read the DOM, read localStorage, call your API with the user's cookies, and rewrite the page.

Three kinds, by how the payload arrives:

Stored — saved on the server (a comment, a display name, a support ticket) and served to every viewer. The most damaging, because it needs no interaction.

Reflected — echoed back from a request parameter, delivered by a crafted link.

DOM-based — never touches the server. Your own JavaScript takes something from the URL, from postMessage, or from storage and writes it into a dangerous place.

The dangerous places, worth knowing as a list, because DOM-based XSS is entirely about which function received the value: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function, a string passed to setTimeout, location/location.href, element.src on a script, iframe.srcdoc, and javascript: in any URL attribute.

The defence ladder

Encode for the context you are inserting into. HTML text, an HTML attribute, a URL, JavaScript and CSS all need different escaping, and encoding for the wrong one does nothing. This is why "escape the input" as a general instruction fails — escaping belongs at output, where the context is known, not at input, where it is not.

Let your framework do it. React, Vue and Angular escape interpolated values by default, which removes the majority of the risk. They all have escape hatches, and those are where the bugs are: dangerouslySetInnerHTML, v-html, [innerHTML] with bypassSecurityTrustHtml. Each one is named to be alarming. Treat every occurrence as a review item.

Sanitise when you genuinely must render user HTML. Use a maintained sanitiser or the browser's own Sanitizer where available. Never a regular expression — HTML has too many ways to encode the same thing, and every hand-rolled filter has been bypassed.

Prefer textContent wherever the value is text (Chapter 6.3.1).

Content Security Policy

A policy header telling the browser which sources are allowed for each kind of resource. The point of CSP is not to prevent injection — it is to make a successful injection harmless, because the injected script has no permission to run.

http
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0mP3rReQu3st' 'strict-dynamic';
  style-src 'self' 'nonce-r4nd0mP3rReQu3st';
  img-src 'self' data: https://cdn.example.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'none';
  form-action 'self';
  object-src 'none';
  report-to csp-endpoint

Directive by directive, because each closes a specific attack:

script-src with a nonce. A random value generated per response, put in the header and on each legitimate <script nonce="…">. An injected script has no nonce and does not execute. The nonce must be unpredictable and must never be reused across responses, or an attacker who has seen one page can include it.

'strict-dynamic' lets a script that the nonce already trusted load further scripts, which is what makes CSP workable with bundlers and tag managers without listing every domain.

'unsafe-inline' defeats the entire policy for that directive, because it permits exactly what an injection produces. It is also ignored when a nonce is present, which is the recommended migration path: add nonces, keep 'unsafe-inline' as a fallback for old browsers, and modern browsers ignore it.

connect-src limits where JavaScript can send data. This is the one that turns a data-theft attack into nothing: injected code that cannot reach evil.example cannot exfiltrate what it read.

frame-ancestors 'none' is the modern anti-clickjacking control, replacing X-Frame-Options. Clickjacking is loading your site in a transparent iframe over a decoy so a click lands on your "Confirm payment" button. frame-ancestors says who may frame you; 'self' or an explicit list where framing is legitimate.

base-uri 'none' stops an injected <base href="https://evil.example/">, which would silently redirect every relative script URL on the page.

form-action 'self' stops an injected form posting your user's credentials elsewhere.

Roll it out in report-only mode. Content-Security-Policy-Report-Only sends violation reports without blocking anything. Run it for a couple of weeks, fix what the reports show, then switch to enforcing. Deploying a strict CSP straight to production reliably breaks something on the checkout page.

Trusted Types is the strongest step available and only in some browsers: require-trusted-types-for 'script' makes assigning a plain string to innerHTML throw, so a dangerous sink cannot be used at all without going through an explicit, reviewable policy function. It eliminates DOM-based XSS structurally rather than by discipline.

4. The rest of the isolation toolkit

<iframe sandbox> strips every capability and you add back only what is needed:

html
<iframe src="/preview" sandbox="allow-scripts"></iframe>

With sandbox and no tokens: no scripts, no forms, no popups, no top-level navigation, and a unique opaque origin, so the frame cannot even reach its own site's storage. Never combine allow-scripts with allow-same-origin for untrusted content — together they let the frame remove its own sandbox attribute and reload.

Cross-Origin-Opener-Policy: same-origin severs the window.opener link to pages you open and pages that open you, which stops a cross-origin window manipulating yours. Combined with Cross-Origin-Embedder-Policy: require-corp it gives cross-origin isolation, which is required for SharedArrayBuffer and high-resolution timers — the Spectre mitigation from Chapters 6.1.1 and 6.3.3. It also breaks most third-party embeds, so it is an architectural decision.

Cross-Origin-Resource-Policy lets a resource declare who may embed it — same-origin, same-site or cross-origin — which is the other half of the isolation pair.

Referrer-Policy: strict-origin-when-cross-origin is a sensible default: full URL to your own origin, only the origin to others, nothing when downgrading to HTTP. Without it, the full path of the page — including anything you unwisely put in a URL (Chapter 5.7) — is sent to every third party you load a resource from.

Permissions-Policy disables features you do not use, for yourself and everything you embed: camera=(), microphone=(), geolocation=(). Cheap, and it limits what an injected script or a third-party frame can even attempt.

X-Content-Type-Options: nosniff stops the browser guessing a content type (Chapter 6.1.1), which is what turns a user-uploaded "image" into an executing script.

Subresource Integrity pins a third-party file's content:

html
<script src="https://cdn.example.com/lib.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+…"
        crossorigin="anonymous"></script>

The browser hashes the fetched file and refuses to execute it if the hash differs. This is the defence against a compromised CDN serving modified code to your users. It requires crossorigin because the browser must be able to read the response to hash it, and it only works for files that never change — which is why it pairs with versioned URLs.

The header set, as a checklist

http
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-…' 'strict-dynamic'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin

Chapter 5.7 covers HSTS, Chapter 5.6.3 covers cookie attributes and CSRF, and Chapter 9.9.5 covers the server side. Set these once, at the edge or in a single middleware, so a new route cannot be deployed without them.

What the interviewer will push on

"Why does the request work in curl but fail in the browser?" CORS is enforced by the browser only, to protect one site's data from another. curl has no origin to protect. Then add the sentence that shows you understand the model: the request was sent and the server answered — the browser refused to hand the response to your JavaScript.

"What does the same-origin policy actually prevent?" Reading cross-origin responses, not sending cross-origin requests. That asymmetry is exactly why CSRF exists — the attacker's request goes through with cookies and they never need to see the reply.

"What triggers a preflight, and why does it exist?" Any method beyond GET/HEAD/POST, Content-Type: application/json, or a custom header. It exists because the real request may have side effects, so permission is asked before the DELETE is delivered. Then volunteer that the real response needs Allow-Origin as well, which is the confusing failure where the OPTIONS succeeds and the request still fails.

"Why can't you use * with credentials?" It would let any site on the internet read authenticated responses. Echo a checked origin instead, and set Vary: Origin so a cache does not serve one origin's response to another.

"Is CORS a security control?" No. It protects other sites' users from your page reading cross-origin data. It does not protect your endpoint from anyone with a terminal. Authentication and authorisation are separate and required.

"What does a Content Security Policy actually change?" It does not prevent injection; it makes an injection harmless because the injected script cannot execute. Name nonces, 'strict-dynamic', and the fact that 'unsafe-inline' defeats the directive — and that a nonce causes it to be ignored, which is the migration path.

"How would you roll out CSP on a live site?" Report-only first, collect violations for a couple of weeks, fix, then enforce. Straight to enforcing breaks checkout.

"How do you defend against XSS in a React application?" Framework escaping handles most of it; the risk concentrates in dangerouslySetInnerHTML, so audit every occurrence. Sanitise with a maintained library, never a regular expression. Add CSP with nonces, and connect-src so stolen data cannot leave.

One thing to volunteer: point out that connect-src is the directive that turns a successful XSS from a data breach into a nuisance — the injected script may read whatever it likes and cannot send it anywhere. Most people list CSP directives as a set of restrictions; naming the one that limits exfiltration shows you have thought about what the attacker does after they succeed.

Recall

  • An origin is scheme + host + port. The same-origin policy blocks reading cross-origin data — it does not block sending cross-origin requests, which is exactly why CSRF exists.
  • Cross-origin resources can be used, not inspected: a canvas becomes tainted, a script's errors are reduced to bare Script error. (fixed with crossorigin plus CORS), stylesheet rules throw, an iframe's document is unreachable except via postMessage, and a no-cors fetch returns an opaque response.
  • CORS is a relaxation of the default, enforced by the browser only. That is why curl is unaffected — and why CORS is not access control.
  • Simple requests are the ones a plain HTML form could already make. A preflight is triggered by any other method, Content-Type: application/json, or a custom header — because the real request may have side effects. The real response must carry Allow-Origin too, and Access-Control-Max-Age stops every call being doubled.
  • With credentials: no wildcard, echo the checked origin, Allow-Credentials: true, and Vary: Origin or a cache leaks one origin's response to another.
  • Real vulnerabilities: blindly reflecting Origin with credentials, allowing null, endsWith/includes origin checks, and trusting subdomains that host user content.
  • XSS is stored, reflected or DOM-based. The sinks: innerHTML, insertAdjacentHTML, document.write, eval, new Function, string setTimeout, location, srcdoc, javascript: URLs. Encode at output, where the context is known; framework escaping covers most cases and the escape hatches are where the bugs live; sanitise with a real library, never a regular expression.
  • CSP makes a successful injection harmless. Per-response nonces + 'strict-dynamic'; 'unsafe-inline' defeats the directive and is ignored when a nonce is present; connect-src blocks exfiltration; frame-ancestors replaces X-Frame-Options against clickjacking; base-uri and form-action close two injection follow-ups. Roll out in report-only first. Trusted Types removes DOM XSS structurally.
  • Isolation: <iframe sandbox> (never allow-scripts with allow-same-origin for untrusted content), COOP/COEP for cross-origin isolation (required for SharedArrayBuffer, a Spectre consequence), CORP, Referrer-Policy, Permissions-Policy, nosniff, and Subresource Integrity against a compromised CDN.

Self-test: What exactly did the browser block when you saw a CORS error? · Why is CSRF a consequence of the same-origin policy rather than a hole in it? · Why must a preflighted request's real response also carry Allow-Origin? · What breaks if you reflect Origin with credentials on? · Which CSP directive stops stolen data leaving, and which one silently disables the policy?

Part 6 ends here. You now have the whole chain: the processes that run a page, the pipeline that turns markup into pixels, the language of layout, the platform APIs, the abstraction almost everyone builds on, the server that renders it, the tools that ship it, the numbers that measure it, the patterns that structure it, the techniques for when the DOM runs out, and the rules that keep all of it from reading each other's data.

Next: Part 7 goes behind the API the frontend has been calling all along — how a database actually stores a row on a disk, why an index turns a scan into a seek, and what a transaction guarantees when two of these pages write at the same moment.