skip to content

Browser Security Model

You will learn the boundaries the browser enforces on your behalf — origins, CORS, CSP, sandboxing, permissions — and how to work with them instead of around them. Interviewers ask because a frontend engineer who disables a protection to make a feature work is a liability.

on this pageshow

explore

questions

29

A fetch() call from a page on https://app.example.com to https://api.other.com fails with a console message saying the response was blocked by CORS policy. Who produced that error, and why does adding an Access-Control-Allow-Origin header to the request headers of your fetch call not fix it?

level: juniorimportance: must knowfreq 82%

answer

  1. the browser is the one complaining
  2. request never sent the permission
  3. response headers, not request headers
  4. curl has no origin to protect
  5. opt-in lives on the server side

basics

~20 s

The browser produced the error, not the server and not your code. CORS permission is granted by headers on the server's response, so Access-Control-Allow-Origin can only be set by the API. Sending it as a request header changes nothing.

solid answer

~50 s

That message comes from the browser's own enforcement, after the response arrived. When a page makes a cross-origin request with `fetch`, the browser attaches an `Origin` header and then checks the response for an `Access-Control-Allow-Origin` value that matches that origin. If it is missing or does not match, the browser refuses to hand the response to my JavaScript and rejects the promise with a `TypeError` — I never see the status or the body. `Access-Control-Allow-Origin` is a *response* header, so putting it in my own request headers is meaningless; worse, adding an unrecognised header can turn a request that was previously allowed straight through into one the browser checks in advance. The fix is always on the server or the edge in front of it: it has to say my origin is allowed. That is also why the same call works fine from curl — CORS is a browser rule, not a network rule.

go deeper

for a junior

Be ready to say plainly that the browser generates the error and the server grants the permission. Know that Access-Control-Allow-Origin is a response header and that the fix is never in your fetch options.

for a middle

Explain the mechanics: the browser attaches the Origin header, matches it against Access-Control-Allow-Origin on the reply, and rejects with an opaque TypeError so no status or body leaks to the page.

for a senior

Show you can debug it — read the real status in the Network panel, recognise that a missing header on an error path masks a 500, and pick between configuring the API, proxying it onto your own origin, or moving the call server-side.

for a principal

Own the policy: who may configure allowed origins, how preview and staging origins are handled without drifting into a permissive wildcard, and why CORS is never a substitute for authentication and authorization on the API itself.

## What the message is really saying A console line of the form "Access to fetch at 'https://api.other.com/x' from origin 'https://app.example.com' has been blocked by CORS policy" is written by the browser. It is not a network failure, not an error the server sent, and not something your code threw. The browser is reporting that it made a cross-origin request, examined the reply, and decided your script is not allowed to read it. An origin is the scheme, host and port taken together. `https://app.example.com` and `https://api.other.com` are different origins, so every request between them is cross-origin and subject to this check. So is `https://app.example.com` versus `http://app.example.com`, and versus `https://app.example.com:8443`. ## Who grants permission Cross-origin reads are denied by default and the *server* opts in. When the browser sends a cross-origin request from a page, it adds an `Origin` request header naming the calling page's origin — your code cannot set or forge that header, the browser owns it. The server answers with, among the ordinary response headers, `Access-Control-Allow-Origin`. If that value is the exact origin string the browser sent (or `*`, for non-credentialed requests), the browser hands the response to your code. If the header is absent, misspelled, or names a different origin, the browser discards the response. The key asymmetry: `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`, `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods` are all **response** headers. There is no request header a page can send that grants itself permission — if there were, the whole mechanism would be worthless, because an attacker's page would simply send it too. ## What your code observes When the check fails, `fetch` rejects with a `TypeError` whose message is deliberately vague. You get no status code, no headers, and no body: ```js try { const res = await fetch("https://api.other.com/data"); console.log(res.status); // never runs on a CORS failure } catch (err) { console.log(err instanceof TypeError); // true — and that is all you learn } ``` The vagueness is intentional. Telling the page "it returned 401" or "it returned 500" would already be a cross-origin information leak, so the browser reports one undifferentiated failure and puts the detail only in the console, where a human — not the script — can read it. This is also why a CORS message can *mask* an ordinary bug. If the API throws a 500 and the error path of its framework does not attach the CORS headers that the success path attaches, the browser reports a CORS failure. The real problem is the 500. The Network panel still shows the response and its status, because DevTools is not bound by the page's origin — read the status there or in the server's own logs rather than trusting the console text. ## Why the same call works outside the browser curl, Postman, a mobile app and your backend all speak the same HTTP. None of them is a web page with an origin to protect, so none of them enforces CORS. "It works in Postman" therefore proves only that the endpoint exists; it says nothing about whether a browser will let a page read it. The same-origin restriction exists because a browser carries the user's ambient authority — cookies, network position inside a corporate LAN — into every request a page makes. ## What actually fixes it Three real options, in descending order of how often they are right: 1. **Configure the API** to return `Access-Control-Allow-Origin` for your origin. This is the correct fix when you or your organisation control the API. 2. **Put the call on your own origin** — proxy `https://app.example.com/api/*` through to the upstream service. The browser then sees a same-origin request and never applies the check. This is the standard answer for a third-party API you cannot configure, and it is what a dev server's proxy option does locally. 3. **Call it from your server** instead of the page, when a secret is involved anyway. What does *not* fix it: adding headers to your request, wrapping the call in try/catch, switching from `fetch` to `XMLHttpRequest` or a library like axios (identical rules — the enforcement is in the browser, not the API you call it with), or disabling the check with a browser flag or extension, which fixes only your machine and hides the problem from you until production. One last knob to know but rarely to use: `fetch(url, { mode: "no-cors" })` stops the error appearing, but it does so by giving you an unreadable response rather than by granting access. Silencing the message is not the same as getting the data.

  • The endpoint works perfectly in curl and Postman. What does that tell you about the CORS problem?
    Almost nothing. curl and Postman are not web pages, so they have no origin and enforce no same-origin rules; they will happily show a response that carries no CORS headers at all. The successful call proves the route exists and the payload is right. Whether a browser page may *read* it depends entirely on response headers you should inspect explicitly — for example with `curl -i -H 'Origin: https://app.example.com'` — not on the request succeeding.
  • Your API starts returning 500s and the console now shows a CORS error instead. Why, and how do you see the real status?
    Many frameworks attach CORS headers in a middleware or filter that the error path bypasses, so the 500 response arrives without `Access-Control-Allow-Origin` and the browser reports the CORS failure rather than the status. The CORS message is a symptom. Read the actual status in the DevTools Network panel or the server logs — both see the response the script is denied — then fix the 500 and make the error path emit the same headers.
  • Does a CORS failure mean the user's data was protected from the calling page?
    It means the *response body* was withheld from that page's script. It does not mean nothing happened: the browser still sent the request in many cases, and it says nothing about other channels. CORS protects reads across origins; it is not an authorization mechanism. The API must still authenticate and authorize every request on its own, because non-browser clients ignore CORS entirely.

saying these in an interview costs you the question

  • Claims the server sent the CORS error message
  • Tries to set Access-Control-Allow-Origin as a request header
  • Says CORS blocks the request from ever leaving the browser
  • Concludes the API is fine because Postman works
  • Proposes a browser flag or extension as the fix

context

open as a page

In the browser DOM, which APIs turn a plain string into live markup, and why does assigning "<script>alert(1)</script>" to an element's innerHTML not run the script while "<img src=x onerror=alert(1)>" does?

level: juniorimportance: must knowfreq 72%

basics

~20 s

innerHTML, outerHTML, insertAdjacentHTML and document.write parse a string as HTML, so untrusted data becomes markup. HTML fragment parsing marks script elements inserted that way as already-started, so they never run, but event-handler attributes such as onerror still fire. Use textContent instead.

open as a page

A page is served from https://shop.example.com/cart. In browser terms, what is that page's origin, and which of https://shop.example.com:443/help, http://shop.example.com/cart and https://api.example.com/cart are same-origin with it?

level: juniorimportance: must knowfreq 80%

basics

~20 s

An origin is scheme plus host plus port: here https://shop.example.com on port 443. Only the :443/help URL is same-origin; switching the scheme to http or the host to api.example.com produces a different origin. Path is never part of it.

open as a page

A page calls navigator.clipboard.writeText() and registers a service worker. Both work when the site is served from http://localhost:3000, but on http://staging.internal the browser reports navigator.clipboard and navigator.serviceWorker as undefined. What rule is the browser applying, and which origins satisfy it?

level: juniorimportance: must knowfreq 68%

basics

~20 s

Powerful browser APIs are gated on secure contexts: HTTPS and WSS origins plus loopback hosts (localhost, 127.0.0.1, ::1). Plain HTTP on staging is not one, so those APIs are absent entirely; window.isSecureContext reports the verdict.

open as a page

A cross-origin fetch to your API worked until you added credentials: 'include' so the session cookie would be sent; now the browser rejects the response and complains about the wildcard in Access-Control-Allow-Origin. What must the API return instead, and why does '*' stop being acceptable once credentials are involved?

level: middleimportance: must knowfreq 68%

basics

~20 s

A credentialed cross-origin response must name the exact calling origin in Access-Control-Allow-Origin and also send Access-Control-Allow-Credentials: true. The wildcard is banned there because '*' means any site may read the response — which, with the user's cookies attached, would expose their logged-in data to every origin.

open as a page

A cross-origin POST from your page fails with a CORS error in the console, yet the record was created on the server anyway. Explain why some cross-origin fetches reach the server before the browser has any permission, and what in your own fetch call decides that the browser asks first instead.

level: middleimportance: must knowfreq 62%

basics

~20 s

CORS controls whether a page may read a cross-origin response, not whether the request is sent. Requests that a plain HTML form could already make are sent immediately and can have side effects; anything beyond that — a method like PUT or DELETE, a Content-Type of application/json, or a custom header — makes the browser check with the server first.

open as a page

A single-page app ships a static index.html from a CDN with a CSP nonce baked into its inline bootstrap script tag. Explain why that nonce provides no protection, and what a nonce-based Content-Security-Policy therefore demands of the response pipeline.

level: middleimportance: must knowfreq 60%

basics

~20 s

A CSP nonce only protects while it is unpredictable and regenerated on every response. Baked into a cached static file it is public, so injected markup can copy it verbatim. Nonce policies require HTML rendered per request.

open as a page

The same-origin policy is often summarised as "a page cannot talk to another origin". In the browser, what does the same-origin policy actually prevent, and which cross-origin behaviours has it never prevented?

level: middleimportance: must knowfreq 72%

basics

~20 s

The same-origin policy blocks reading, not sending. It stops a page from touching another origin's DOM, response bodies and storage, but it never stopped it from loading cross-origin images and scripts, submitting forms, or navigating — those requests still go out with cookies.

open as a page

After a site enables a Content-Security-Policy with script-src 'self', the browser console reports that the page refused to evaluate a string as JavaScript. Which code patterns cause that, and what are the options for fixing them?

level: juniorimportance: should knowfreq 50%

basics

~20 s

CSP blocks turning strings into code: eval(), the Function constructor, and setTimeout or setInterval called with a string body. Fix by rewriting those call sites — usually a runtime template compiler or a dev build setting — rather than adding 'unsafe-eval'.

open as a page

A partner says your page refuses to render inside their <iframe>. Which two HTTP response headers control whether a document may be framed, and how does X-Frame-Options differ from the Content-Security-Policy frame-ancestors directive?

level: juniorimportance: should knowfreq 46%

basics

~10 s

Two response headers decide it: the legacy X-Frame-Options, which only offers DENY or SAMEORIGIN, and the Content-Security-Policy directive frame-ancestors, which takes a source list. Where both appear, browsers honour frame-ancestors and ignore X-Frame-Options.

open as a page

A Content-Security-Policy can be delivered as an HTTP response header or as a <meta http-equiv="Content-Security-Policy"> element. What can the meta form not do, and when is it still the right choice?

level: middleimportance: should knowfreq 45%

basics

~20 s

A meta-delivered CSP is ignored for frame-ancestors, sandbox and the reporting directives, cannot be report-only, and applies only to markup after it in the document. It is the right choice when you cannot set response headers, or inside an iframe srcdoc document.

open as a page

Your app must render user-submitted rich text as real HTML, so textContent is not an option. What does a sanitizer such as DOMPurify actually do, and at what point in the flow must it run?

level: middleimportance: should knowfreq 42%

basics

~20 s

A sanitizer parses the markup into an inert DOM, walks every node, and removes anything outside an allowlist of elements, attributes and URL schemes — event handlers first. Run it at the sink, immediately before insertion, not once when the value is stored.

open as a page

A page takes a URL from an untrusted source and assigns it to an anchor's href or to location.href. Explain why that is an XSS sink in the browser, and what check actually makes it safe.

level: middleimportance: should knowfreq 45%

basics

~20 s

URL-bearing attributes accept the javascript: scheme, so an attacker-supplied URL becomes code running in your origin when the link is followed or the navigation happens. Parse the value with new URL() and allow only http: and https:; string prefix checks are bypassable.

open as a page

What do the response headers Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp each do to a document, and why must a page send both before window.crossOriginIsolated is true?

level: middleimportance: should knowfreq 38%

basics

~20 s

COOP: same-origin cuts the document off from cross-origin windows that could hold a reference to it; COEP: require-corp blocks cross-origin subresources that have not opted in. Both are required because crossOriginIsolated means nothing untrusted shares the process — in either direction.

open as a page

Browsers key some behaviour to an "origin" and other behaviour to a "site". How does a browser compute a site, how does that differ from an origin, and name something keyed to each.

level: middleimportance: should knowfreq 45%

basics

~20 s

An origin is scheme+host+port; a site is the registrable domain — the public suffix plus one label, computed from the Public Suffix List — plus the scheme under schemeful same-site. Web storage is keyed by origin; cookie same-site decisions are keyed by site.

open as a page

In the browser, what does navigator.permissions.query({ name: 'geolocation' }) actually tell you, what values can the resulting PermissionStatus.state hold, and what does the call deliberately not do?

level: middleimportance: should knowfreq 45%

basics

~20 s

It reads the current permission state without any user interface, resolving to a PermissionStatus whose state is 'granted', 'denied' or 'prompt'. Querying never asks the user; only the feature's own API, such as getCurrentPosition(), triggers a prompt.

open as a page

A copy button's click handler does `const text = await fetch('/doc').then(r => r.text())` and then calls `navigator.clipboard.writeText(text)`, and the write rejects with a NotAllowedError even though the user definitely clicked. What browser rule causes this, and how do you restructure the handler?

level: middleimportance: should knowfreq 42%

basics

~20 s

Clipboard writes require transient user activation — a short-lived, consumable token created by the click. Awaiting a network round trip lets it expire, so the later call is treated as unprompted. Fetch the text before the click, or hand ClipboardItem a promise.

open as a page

To silence a CORS error, a teammate adds mode: 'no-cors' to a fetch() call and reports that it now resolves without complaint. What does the browser actually hand back in that mode, and when is no-cors a legitimate choice rather than a way of hiding a bug?

level: seniorimportance: should knowfreq 42%

basics

~20 s

no-cors returns an opaque Response: status reads as 0, headers are empty, ok is false, and the body cannot be read. It never grants access to data — it only stops the error. It is legitimate only for fire-and-forget requests or caching a resource you never need to inspect.

open as a page

You ship a Content-Security-Policy-Report-Only header and the collector immediately fills with thousands of violations you cannot reproduce locally. How do you separate real breakage from noise before switching the policy to enforcing?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Most of that volume comes from browser extensions and injecting middleboxes, not your code. Triage by grouping on effectiveDirective plus sourceFile, discarding non-http schemes such as extension URLs, and listening for securitypolicyviolation in the page to attach route and release context.

open as a page

A bundled app under a nonce-based Content-Security-Policy boots fine, but every lazily loaded chunk it injects at runtime is blocked. Why does the entry script pass while its children fail, and what are the two ways to fix it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Scripts created with document.createElement carry no nonce, so a nonce-only policy blocks them. Either propagate the nonce onto each injected element (bundlers expose a hook for this), or add 'strict-dynamic', which extends trust from an already-trusted script to the ones it creates.

open as a page

What does the CSP directive require-trusted-types-for 'script' change about DOM sinks such as innerHTML, and what job does a policy created with trustedTypes.createPolicy() do?

level: seniorimportance: should knowfreq 28%

basics

~20 s

With require-trusted-types-for 'script' in force, DOM sinks stop accepting plain strings and throw a TypeError; they take only TrustedHTML, TrustedScript or TrustedScriptURL objects. Only a policy registered through trustedTypes.createPolicy() can mint those, so every unsafe write is funnelled through reviewable code.

open as a page

Some documents in a browser have an opaque origin rather than a normal scheme/host/port one. What is an opaque origin, which documents get one, and what stops working for script running inside such a document?

level: seniorimportance: should knowfreq 32%

basics

~20 s

An opaque origin is an internal unique value with no scheme, host or port. It serializes to the string "null", is never same-origin with anything — not even a second copy of itself — so origin-keyed storage such as localStorage and IndexedDB throws SecurityError there.

open as a page

A page embeds a cross-origin map widget with <iframe src="https://maps.example/w" allow="geolocation">, and geolocation inside the frame still fails immediately without ever prompting the user. The same widget works when opened as a top-level page. What does Permissions-Policy have to do with it?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Delegation is an intersection: a frame gets a feature only if the embedding document is itself permitted to use it and passes it down. If the top document's Permissions-Policy response header omits the widget's origin, the frame is disabled no matter what the container markup says.

open as a page

Your single-page app is served from one origin and its API from another, and calls carry the user's session. How would you decide between keeping that cross-origin arrangement with CORS and serving the API from the app's own origin through a reverse proxy path such as /api?

level: principalimportance: should knowfreq 34%

basics

~20 s

Same-origin proxying removes CORS, advance permission checks and cross-site cookie exposure entirely, at the cost of an extra hop you must operate. Cross-origin with CORS is right when many independent or third-party clients need the API; a first-party SPA with a session usually belongs on one origin.

open as a page

You own the frontend of a product that wants two browser permissions — notifications and geolocation. Given that a denial is stored per origin and no script can reset it, how do you decide when and how each prompt is triggered?

level: principalimportance: should knowfreq 26%

basics

~20 s

Treat each prompt as a one-shot resource. Ask only at the moment the feature is obviously useful, always behind an explicit user action, gate the real prompt behind your own in-app ask you can safely repeat, and design a working experience for users who never grant.

open as a page

After you add Cross-Origin-Embedder-Policy: require-corp to a page, several third-party images, a font from a CDN and an analytics iframe stop loading. Explain why, and walk through the options for restoring each one.

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

require-corp blocks every cross-origin subresource that has not opted in. Restore each by getting Cross-Origin-Resource-Policy: cross-origin from the provider, refetching in CORS mode, switching to COEP: credentialless, or self-hosting. Framed documents must send their own COEP header.

open as a page

Chrome's Site Isolation and Firefox's Fission place documents from different sites in different operating-system processes. What attack made a process boundary necessary when the same-origin policy was already enforced in browser code, and what does that boundary protect that in-process checks cannot?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Spectre-class speculative-execution side channels let script read any byte in its own process, regardless of language or same-origin checks. A process boundary is the answer because it removes the foreign data from the address space entirely, rather than relying on code that speculation can bypass.

open as a page

Two pages, one on https://app.example.com and one on https://example.com, historically relaxed the same-origin check between them by assigning to document.domain. What did that assignment actually change, and why are browsers removing it?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Assigning document.domain widened a document's host for DOM-access checks only, and both pages had to assign it because doing so also blanks the origin's port. It never affected the Origin header, cookies or storage, and Chrome 115 disabled it by default because it breaks origin isolation.

open as a page

A team wants to enable cross-origin isolation (COOP and COEP) across your product so a WebAssembly feature can use SharedArrayBuffer, but the product embeds several third-party widgets and uses a popup-based OAuth flow. How would you decide whether to do it, and how would you roll it out?

level: principalimportance: nice to knowfreq 17%

basics

~20 s

Scope it before costing it: cross-origin isolation is per-document, so isolate the route or origin that needs the capability rather than the whole product. Then inventory third-party embeds with report-only headers, and check whether the popup auth flow survives COOP.

open as a page