SecurityGENERALSPEC-EVOLVINGBROWSER-SPECIFIC

The Same-Origin Policy

Embedding is allowed, reading is not — and the gap between "the request was sent" and "your code may see the answer" is where most browser security confusion lives.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What exactly is my page prevented from doing to another origin, and what is it still perfectly free to do?

The user intent

Someone is signed into their bank in one tab and reading a forum in another. They expect the forum to be unable to see the balance, and they never think about it.

The obvious build

The same-origin policy blocks cross-origin traffic. If my request went out, the policy did not apply, and if it was blocked, the policy applied to the request.

Why it breaks

The request usually goes out. The policy withholds the *response* from your script. A form post to another origin, an image load, a script tag — all of them cross origins and all of them reach the server (Cross-Site Request Forgery).

How it breaks in a real browser
  • The request usually goes out. The policy withholds the *response* from your script. A form post to another origin, an image load, a script tag — all of them cross origins and all of them reach the server (Cross-Site Request Forgery).
  • That gap is not an oversight; it is the entire reason cross-site request forgery exists as a separate class of bug with separate defences.
  • The policy is not one rule. Reading a frame's DOM, reading a fetch response, reading pixels from a canvas, reading a stylesheet's rules and reading an error's stack trace are five checks with five different behaviours.
  • Some of it is relaxable and some is not. document.domain used to loosen frame access and is being removed; postMessage is the supported channel and it is opt-in on both sides.
  • The error you see rarely names the mechanism precisely. "Blocked by CORS policy" is printed for several unrelated situations, one of which is that your server returned a 500 (CORS).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The default rule: script from one origin may not read the DOM, storage, cookies or response bodies of another origin. Everything else follows from that one sentence.
  • Embedding is permitted, inspecting is not. You may render an image from any origin and not read its pixels. You may execute a script from any origin and not read its source text. You may frame a page and not touch its document.
  • The policy is enforced at the point of access, not at the point of request. The browser fetches, then decides what your code is allowed to observe about the result — status, headers, body, or in the opaque case, nothing at all.
  • Opaque responses are the visible shape of this. A no-cors fetch resolves with a response whose status reads as 0 and whose body you cannot read; it is useful for cache warming and useless for data.
  • Tainting applies the same idea to pixels and audio. Drawing a cross-origin image into a canvas marks it tainted, and getImageData then throws — unless the image was requested with crossorigin and the server allowed it (Images, Video and the Elements That Own Their Layout).
  • Deliberate channels exist: postMessage between windows and frames, BroadcastChannel within an origin, CORS for responses, and Cross-Origin-Resource-Policy / Cross-Origin-Opener-Policy for the opposite direction — declaring who may embed or reference *you*.
  • Cookies follow their own scoping rules, keyed by domain and path rather than by origin, which is why a cookie can be visible to a page that cannot read your DOM (Cookies).

What this makes the browser do

And which of it is avoidable.

  • A check on every cross-document property access. Reaching for iframe.contentDocument is a security decision, not a property read, and it throws rather than returning null.
  • Tracking taint on canvases, media elements and audio contexts for the lifetime of the object, so a single cross-origin draw permanently changes what the canvas can be asked for.
  • Filtering response metadata: an opaque response still occupies the HTTP cache and still cost the network, but the browser hands script a stripped object.
  • Increasingly, keeping cross-origin data out of the renderer process entirely rather than fetching it and refusing to show it, so that a compromised renderer never holds bytes it was not allowed to read (The Multi-Process Browser).
  • Avoidable work: fetching a resource no-cors "to check whether it exists". The response tells you nothing, and you have paid for the request.

Embed freely, read almost nothing

The clearest way to hold this policy in your head is as an asymmetry rather than a wall. The browser is generous about *using* another origin's resources and extremely stingy about *inspecting* them. Every confusing case fits that shape once you ask which of the two you are attempting.

The practical consequence is that "it loaded" and "I can look at it" are separate outcomes, and the second requires the other origin to have opted in. Nothing you write on your side changes that.

  • Render an image: allowed. Read its pixels: blocked, unless requested with crossorigin and permitted.
  • Execute a script: allowed. Read its source, or a useful stack trace from it: blocked without crossorigin.
  • Frame a document: allowed unless it refuses. Touch anything inside it: blocked, always (Clickjacking and Framing).
  • Send a fetch: allowed. Read the body, the status or most headers: only with CORS (CORS).
  • Submit a form across origins: allowed, with cookies attached. This is the whole of Cross-Site Request Forgery.
Your page reaching across an origin boundary
allowedallowedallowedrequest is sentresponse returnsbody only if the server opted inyour page https://app.example.com<img src> render it<script src> execute it<iframe src> display itfetch() read the bodyhttps://other.example a different originread check at access time
UserLLMAgentToolDataDecisionHumanGuardrail

Five different reads, five different behaviours

Calling it "the" same-origin policy hides that different resource types fail differently, and the failure shape is the diagnostic. Knowing which of these you are in tells you immediately whether the fix is a request option, a server header, or an architecture change.

What you tried to readWhat happensHow to make it legitimateWhat it costs
A cross-origin frame's documentThrows a security error on property accesspostMessage in both directions, with origin checks on both endsA protocol you own, version and validate
A fetch response bodyPromise rejects, or resolves opaque under no-corsCORS headers naming your origin, from the responding serverServer configuration, and possibly a preflight round trip
Canvas pixels after drawing a remote imagegetImageData throws; the canvas is permanently taintedcrossorigin="anonymous" on the image plus a permissive serverThe image is refetched under different credentials rules
cssRules of a cross-origin stylesheetThrows on access; the sheet still applies visuallyServe the stylesheet same-origin, or with CORS and crossoriginLoses the CDN's default configuration; someone must own the header
A stack trace from a cross-origin scriptReported as a bare "Script error" with no detailcrossorigin on the script tag plus an allowing serverYour error tracker becomes dependent on a vendor's header

The channel you are meant to use

When two origins genuinely need to talk, postMessage is the supported route. It is also the place teams most reliably rebuild the vulnerability the policy was preventing, because the browser delivers the message and leaves every trust decision to you.

Two rules make it safe and both are routinely skipped: name the target origin when sending, so the message is not delivered to whatever document happens to occupy the frame, and check event.origin against an allowlist on receipt before looking at the data at all.

A receiver that actually checks
1const ALLOWED = new Set(['https://widget.example.com'])
2
3window.addEventListener('message', (event) => {
4 // 1. Who sent it. Do this BEFORE touching event.data.
5 if (!ALLOWED.has(event.origin)) return
6
7 // 2. Optional but worth it: is it the window we handed the frame to?
8 if (event.source !== frameRef.current?.contentWindow) return
9
10 // 3. What is it. The origin is trusted; the payload still is not.
11 const msg = parseWidgetMessage(event.data) // narrow, reject unknown shapes
12 if (!msg) return
13
14 applyWidgetHeight(msg.height)
15})
16
17// Sending: name the origin. '*' delivers to whatever document is there now.
18frameRef.current?.contentWindow?.postMessage(
19 { type: 'theme', value: 'dark' },
20 'https://widget.example.com',
21)

The ordering is the point. Checking the origin after reading event.data means a hostile sender's payload has already been through your parsing code, which is the part with the bugs.

How to build it

Most important first.

  • Design for the boundary rather than around it. If two things must read each other, put them on one origin; if they must not, use an explicit channel (Origins and the Sandbox).
  • Use postMessage with an explicit targetOrigin, and validate event.origin on receipt. A handler that trusts any sender has re-created the vulnerability the policy was preventing.
  • Set crossorigin on images and fonts you intend to read or measure, and make sure the server allows it — otherwise the failure appears much later, as a throw inside a canvas export.
  • Prefer Cross-Origin-Resource-Policy on your own responses to declare who may embed them; it is the direction of control most teams forget exists.
  • Never use a browser flag or a disabled-security profile to make development work. It validates your code against a browser nobody runs (A Method for Frontend Bugs).
  • When you need to expose data across origins, decide it server-side, name the origins explicitly, and treat the list as configuration under review (CORS).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A cross-origin iframe is opaque to the parent, which means the parent cannot label it, cannot manage focus inside it, and cannot include its content in a live region. The embedded document must supply its own title, lang and heading structure (The Head: Metadata That Changes Rendering).
  • Give every iframe a title attribute. Screen readers announce it as the frame's name, and an unlabelled frame is announced as an unnamed region a user must enter blind.
  • Focus moving into and out of an embedded document is a context change that the parent cannot smooth over. Keyboard users feel the discontinuity even when nothing is broken (Keyboard Operability).
  • When a cross-origin read fails and a feature degrades, announce the degradation. A widget that renders as an empty box is a silent failure for anyone not looking at it (Live Regions and Announcement).

What can go wrong

Failure modes
  • A postMessage listener with no origin check — the classic way a team reintroduces cross-origin reads while believing the platform is protecting them.
  • A canvas that works in development, where the image is same-origin, and throws in production behind a CDN on a different host (CDN Delivery).
  • Error handlers that report "Script error" with no message or stack, because the failing script is cross-origin and was loaded without crossorigin (Frontend Error Tracking).
  • Stylesheet introspection failing: cssRules on a cross-origin stylesheet throws, which breaks font-loading and theming code that worked when everything was on one host (The CSSOM).
  • Assuming an opaque response means success. It resolves whether the server returned 200 or 500, so any logic built on it is reading noise.
  • The mitigation failing: an origin allowlist maintained by hand that grows a wildcard entry during an incident and stays that way.
What can arrive out of order
  • postMessage is asynchronous and unordered relative to load: a message can arrive before the receiving frame has attached its listener, so the protocol needs a ready handshake rather than a hopeful send.
  • Frame navigation races message delivery. A frame that navigates between your origin check and your handler can leave you acting on a message from a document that no longer exists (Node Identity Across Updates).
  • A cross-origin image can finish loading after the code that draws it has already read the canvas, producing a taint error that only appears on slow networks.
Security
  • This is the property that makes browsing an unknown site safe at all. Every restriction that feels obstructive is the cost of that guarantee.
  • The policy does not prevent a request from being *sent*, so any state-changing endpoint that relies on "only our page can call this" is already exposed (Cross-Site Request Forgery).
  • It does not apply to script running in your own page. An injected script is same-origin and inherits every permission you have (Cross-Site Scripting).
  • It does not apply outside a browser. A script, a proxy or a mobile client sends whatever it likes; the server is the only place a rule holds (What the Frontend Is Responsible For in Auth).
  • Cross-origin isolation headers exist because timing side channels can leak across the boundary the policy draws. Precise timers and shared memory are gated on that isolation for exactly this reason (Shared Memory and Cross-Origin Isolation).
Misreads
  • "The same-origin policy blocked my request." Almost always the request was sent and the response was withheld. That difference is the whole of Cross-Site Request Forgery.
  • "An opaque response with no error means it worked." It means the browser will not tell you either way.
  • "Adding CORS headers weakens the same-origin policy in general." It relaxes exactly one thing — whether script may read that response from that origin — and nothing else.
  • "The policy protects my API from other sites." It governs what *browser script* may read. A server that changes state on an unauthenticated request is exposed regardless.
  • "postMessage is safe because the browser routes it." The browser routes it. Validating who sent it and what it says is yours (Parse, Validate, Authorize, Process).

Measuring it, and what changes in the field

How you would see this
  • The console distinguishes the failure modes and the distinction is the fix: a blocked read, a missing header, a disallowed method, a wildcard used with credentials (Debugging the Network).
  • The Network panel shows the request that was sent even when your code cannot read the answer — proof that the policy withheld a response rather than blocking a request.
  • An error tracker reporting bare "Script error" entries at volume is telling you a cross-origin script is throwing and you have no crossorigin attribute on the tag (Frontend Error Tracking).
  • Frame relationships are visible in the devtools frame tree, which is the quickest way to see how many origins are actually executing in your page (Third-Party Scripts and the Supply Chain).
Slow device, slow network, large data, old tab
  • A single-origin application never meets most of this, which is why teams that split an app across origins late in its life hit every item on this list within a week.
  • Behind a CDN or an image proxy, assets that were same-origin become cross-origin without any code change, so canvas and font code breaks at deploy rather than at edit (CDN Delivery).
  • In embedded contexts — a widget inside someone else's page — you are the cross-origin party, and your storage may be partitioned or unavailable (Storage Security and Durability).
  • On slow networks, an opaque response still costs the full transfer; the policy saves you nothing in bytes, only in what you may read.
What this costs
  • Consolidating onto one origin removes an entire class of problem and couples deployment, caching and blast radius: an injection anywhere in that origin is an injection everywhere in it.
  • Splitting across origins buys isolation and costs CORS configuration, preflights, cookie scoping and a postMessage protocol you now own and must version.
  • postMessage gives you a legitimate channel and hands you the responsibility of validating both origin and payload — a hand-rolled trust boundary in application code (Parse, Validate, Authorize, Process).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe read-versus-embed distinction, opaque responses, canvas tainting and postMessage semantics are specified and behave the same across Blink, Gecko and WebKit; disagreements are about edge cases such as sandboxed frames rather than about the rule.
  • SPEC-EVOLVINGdocument.domain as a way to relax frame access is deprecated and already disabled by default in Chromium unless a page opts back in via Origin-Agent-Cluster; Safari and Firefox are on their own timelines, so any code depending on it has a different expiry date per browser.
  • BROWSER-SPECIFICHow much cross-origin data is kept out of the renderer process at all differs by engine — Chromium's cross-origin read blocking is an implementation detail, not a specified guarantee, so never treat "the bytes never reached the process" as portable.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designhttp-semantics
Domains that do not exist yet
  • Programming Languages & Runtime Internals — why a cross-origin script's exception loses its stack before it ever reaches your onerror handler, at the level of what the engine retains.
OS & Networkinghttp-basics