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.
What exactly is my page prevented from doing to another origin, and what is it still perfectly free to do?
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 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.
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).
- 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
fetchresponse, 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.domainused to loosen frame access and is being removed;postMessageis 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).
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-corsfetch 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
getImageDatathen throws — unless the image was requested withcrossoriginand the server allowed it (Images, Video and the Elements That Own Their Layout). - Deliberate channels exist:
postMessagebetween windows and frames,BroadcastChannelwithin an origin, CORS for responses, andCross-Origin-Resource-Policy/Cross-Origin-Opener-Policyfor 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.contentDocumentis 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
crossoriginand 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.
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 read | What happens | How to make it legitimate | What it costs |
|---|---|---|---|
A cross-origin frame's document | Throws a security error on property access | postMessage in both directions, with origin checks on both ends | A protocol you own, version and validate |
A fetch response body | Promise rejects, or resolves opaque under no-cors | CORS headers naming your origin, from the responding server | Server configuration, and possibly a preflight round trip |
| Canvas pixels after drawing a remote image | getImageData throws; the canvas is permanently tainted | crossorigin="anonymous" on the image plus a permissive server | The image is refetched under different credentials rules |
cssRules of a cross-origin stylesheet | Throws on access; the sheet still applies visually | Serve the stylesheet same-origin, or with CORS and crossorigin | Loses the CDN's default configuration; someone must own the header |
| A stack trace from a cross-origin script | Reported as a bare "Script error" with no detail | crossorigin on the script tag plus an allowing server | Your 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.
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)) return6 7 // 2. Optional but worth it: is it the window we handed the frame to?8 if (event.source !== frameRef.current?.contentWindow) return9 10 // 3. What is it. The origin is trusted; the payload still is not.11 const msg = parseWidgetMessage(event.data) // narrow, reject unknown shapes12 if (!msg) return13 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
postMessagewith an explicittargetOrigin, and validateevent.originon receipt. A handler that trusts any sender has re-created the vulnerability the policy was preventing. - Set
crossoriginon 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-Policyon 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,langand heading structure (The Head: Metadata That Changes Rendering). - Give every iframe a
titleattribute. 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
- A
postMessagelistener 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:
cssRuleson 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.
postMessageis 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.
- 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).
- "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.
- "
postMessageis 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
- 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
crossoriginattribute 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).
- 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.
- 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
postMessageprotocol you now own and must version. postMessagegives 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
postMessagesemantics 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-EVOLVING
document.domainas a way to relax frame access is deprecated and already disabled by default in Chromium unless a page opts back in viaOrigin-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.
- — Programming Languages & Runtime Internals — why a cross-origin script's exception loses its stack before it ever reaches your
onerrorhandler, at the level of what the engine retains.