Every hero has an origin story. Turns out your browser has one too, and it's the single most important line of defense standing between the tab where you check your bank account and the tab playing some sketchy free-movie site. This post is the origin story of the origin (yeah, I'll stop).

Hello world! If you've read my other posts you know I've been obsessed with browsers for a while now. But most of the time when people talk about "browser security" they jump straight into the fun stuff, sandbox escapes, V8 type confusions, memory corruption, all that shiny weaponized chaos. And that's great (I love it). But there's a much quieter, much more fundamental thing that holds the entire web together, and almost nobody sits down to understand it deeply. That thing is the Same Origin Policy, or SOP.

So this post is a bit different from my usual ones, there's no CVE, no 0day, no crash. Instead I want to do a full deep dive into what I'll call the SOP INTERNALS: how Chromium actually implements the Same Origin Policy under the hood, from the definition of an "origin" all the way down to the exact C++ that decides whether one frame is allowed to touch another. I'll cut the boring parts (the RFC-lawyer stuff) and keep just enough to actually understand how it works. Let's go :D


Why does the SOP even exist?

Before we open a single source file, we need to understand the why. And to understand the why, you have to imagine a web without a Same Origin Policy. Seriously, stop and picture it for a second.

You open two tabs. One is your-bank.com, logged in, session cookie and everything. The other is totally-legit-free-games.biz. In a world with no SOP, the JavaScript running on that random games site could just... open an invisible iframe pointing at your bank, read the DOM inside it, scrape your balance, grab the CSRF tokens, fire off a transfer, and read the response. No exploit needed. No bug needed. That's just how the web would work. The whole idea of "logging in" would be meaningless, because any site could act on your behalf on any other site.

The SOP is the rule that says: content from one origin cannot read content or state from another origin. It's the invisible wall between tabs, between iframes, between everything. If you want a really good, visual, intuitive intro to why this policy has to exist, LiveOverflow made a fantastic video on it and I genuinely recommend pausing here and watching it, it's the exact mental model you want before diving into the code: The Same Origin Policy explained (LiveOverflow).

Historically this idea is old. The Same Origin Policy was introduced by Netscape Navigator 2.02 back in 1995, right after JavaScript showed up in Netscape 2.0 and suddenly pages could programmatically touch the DOM. The moment scripting became a thing, the browser vendors realized "oh no, one page can now read another page", and the SOP was born to protect DOM access. A year later, in 1996, Netscape Navigator 3 introduced document.domain to relax that policy on purpose (we'll get to that horror later hehehe). So this policy is basically as old as client-side JavaScript itself. It has been patched, extended, and reasoned about for 30 years, and it's still the foundation everything else is built on.

CORS, CSP, cookies' SameSite, COOP/COEP, site isolation... all of these are either extensions of, or reactions to, the Same Origin Policy. If you understand the SOP internals, everything else clicks into place.

Okay but what IS an "origin"?

Everyone repeats the same sentence: "an origin is the tuple of scheme, host, and port". And that's true, but it's the surface. Let me give the real definition, because in Chromium an origin is actually one of two very different things, and understanding the second one is where the SOP INTERNALS start getting interesting.

In Chromium, the canonical representation lives in url::Origin. The class comment literally spells it out, an origin is either:

// src: https://source.chromium.org/chromium/chromium/src/+/main:url/origin.h
// An Origin is either:
// - a tuple origin of (scheme, host, port), as described in RFC 6454, or
// - an opaque origin with an internal value, and a memory of the tuple origin
//   from which it was derived (its "precursor").
//
// The tuple is stored in |tuple_|. For opaque origins, |nonce_| is set.
class Origin {
  ...
  SchemeHostPort tuple_;
  std::optional<Nonce> nonce_;   // present ONLY for opaque origins

  bool opaque() const { return nonce_.has_value(); }
};

So the two kinds of origin are:

The tuple itself is a separate little class, SchemeHostPort, which is just the three fields plus normalization logic:

// src: https://source.chromium.org/chromium/chromium/src/+/main:url/scheme_host_port.h
// Represents the (scheme, host, port) of a tuple origin.
class SchemeHostPort {
  ...
  std::string scheme_;
  std::string host_;
  uint16_t port_;
};

One super important detail that a LOT of people get wrong: the path is NOT part of the origin. https://site.com/a and https://site.com/b are the exact same origin. The query, the fragment, the path, none of it matters. Only scheme+host+port. This is also exactly why Chromium's own security docs scream at developers to use origins and not URLs for security decisions, more on that at the end.

SOP INTERNALS: the two layers

Here's the first thing that surprised me when I started reading the actual code. There isn't one Same Origin Policy implementation in Chromium. There are basically two representations of an origin that have to agree with each other:

  1. url::Origin (in //url) — the browser-process / "everywhere" representation. This is the one used in the C++ backend, in Mojo IPC, in network code, etc.
  2. blink::SecurityOrigin (in Blink, the renderer) — the representation the actual rendering engine uses when it's deciding whether one frame's JavaScript can touch another frame's DOM.

These two must stay in sync, and Chromium even has tests and past bugs specifically about making blink::SecurityOrigin and url::Origin agree with each other. When I say "SOP INTERNALS" for the rest of this post, I'm mostly talking about the Blink side, SecurityOrigin, because that's the layer that actually gates DOM access, which is the original thing the SOP was invented to protect.

Let's open it up.

SOP INTERNALS: SecurityOrigin, the renderer's gatekeeper

The SecurityOrigin class is the heart of the SOP INTERNALS inside Blink. Its fields tell you almost everything about how the policy works, just look at what it stores:

// src: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/weborigin/security_origin.h
class SecurityOrigin {
  ...
  String protocol_ = g_empty_string;   // scheme
  String host_ = g_empty_string;       // host
  String domain_ = g_empty_string;     // effective domain (document.domain!)
  uint16_t port_ = 0;

  // For opaque origins: the unique token that makes this origin
  // same-origin with literally nothing but itself.
  std::optional<base::UnguessableToken> nonce_if_opaque_;

  // The tuple this opaque origin was derived from (its precursor).
  scoped_refptr<const SecurityOrigin> precursor_origin_;

  bool universal_access_ = false;
  bool domain_was_set_in_dom_ = false;
  bool can_load_local_resources_ = false;
};

Notice a few things immediately:

The constructor sets domain_ to be the same as host_ initially, this matters a lot for the document.domain discussion:

// src: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/weborigin/security_origin.cc
SecurityOrigin::SecurityOrigin(const String& protocol,
                               const String& host,
                               uint16_t port)
    : protocol_(protocol),
      host_(host),
      domain_(host_),          // domain starts as host
      port_(port) {
  ...
  can_load_local_resources_ = IsLocal();
}

SOP INTERNALS: the actual same-origin check

This is the function. This is the one. If you only remember one piece of code from this entire post, make it SecurityOrigin::IsSameOriginWith. This is the literal implementation of "are these two things the same origin":

// src: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/weborigin/security_origin.cc
bool SecurityOrigin::IsSameOriginWith(const SecurityOrigin* other) const {
  if (this == other)
    return true;

  // Opaque origins are same-origin ONLY with themselves,
  // compared purely by nonce.
  if (IsOpaque() || other->IsOpaque())
    return nonce_if_opaque_ == other->nonce_if_opaque_;

  // Otherwise: strict tuple comparison. host, then scheme, then port.
  if (host_ != other->host_)
    return false;

  if (protocol_ != other->protocol_)
    return false;

  if (port_ != other->port_)
    return false;

  if (IsLocal() && !PassesFileCheck(other))
    return false;

  return true;
}

Read it slowly, because it's beautiful in how simple it is:

  1. Same pointer? Same origin, done.
  2. Either one opaque? Then they can only be same-origin if their nonce_if_opaque_ values match. A non-opaque origin is never same-origin with an opaque one, and two different opaque origins (different nonces) are never same-origin either. This is the "same-origin with nothing but itself" property, implemented in one line.
  3. Both are tuple origins? Then it's the classic scheme + host + port comparison. All three must match exactly.
  4. Local (file:) scheme? There's an extra check because file: URLs are their own special nightmare.

That's it. That's the "same origin" in Same Origin Policy. Notice how it maps exactly to the two kinds of origin we saw in url::Origin: tuple origins compare by tuple, opaque origins compare by nonce.

SOP INTERNALS: from "same origin" to "can you access this?"

Now, "same origin" and "allowed to access" are not the same thing, and this trips people up. The function that actually gates cross-frame DOM access is SecurityOrigin::CanAccess, and it's built on top of the same-origin logic but adds two extra concepts: document.domain relaxation and agent clusters.

// src: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/weborigin/security_origin.cc
bool SecurityOrigin::CanAccess(const SecurityOrigin* other,
                               AccessResultDomainDetail& detail) const {
  // God mode: some special origins are allowed to touch everything.
  if (universal_access_) {
    detail = AccessResultDomainDetail::kDomainNotRelevant;
    return true;
  }

  // The real check, this is where document.domain is honored.
  bool can_access = IsSameOriginDomainWith(other, detail);

  // Even if same-origin-domain, both frames must live in the SAME
  // agent cluster, or access is denied.
  if (can_access && !cross_agent_cluster_access_ &&
      !agent_cluster_id_.is_empty() && !other->agent_cluster_id_.is_empty() &&
      agent_cluster_id_ != other->agent_cluster_id_) {
    detail = AccessResultDomainDetail::kDomainNotRelevantAgentClusterMismatch;
    can_access = false;
  }

  return can_access;
}

So the access decision is really three gates stacked:

  1. universal_access_ — a bypass for privileged contexts. If it's set, everything is allowed. (This is a flag you never want set on an attacker-controlled origin, obviously.)
  2. IsSameOriginDomainWith — same-origin, but taking document.domain into account.
  3. Agent cluster match — even if the origins are "the same domain", the two documents must be in the same agent cluster (roughly: the same synchronous scripting group). This is a newer defense that limits the blast radius of document.domain and cross-origin isolation.

This CanAccess is what ultimately gets called, through Blink's binding layer, whenever JavaScript in one window reaches for another window's properties. That binding-layer entry point lives here:

// src: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/bindings/core/v8/binding_security.cc
// BindingSecurity::ShouldAllowAccessTo(...) is the V8-binding
// chokepoint that guards cross-window property access.
// It resolves both frames' SecurityOrigins and ultimately asks
// accessing_origin->CanAccess(target_origin).
//
// If this returns false, Blink throws a SecurityError (or returns
// undefined) instead of letting the read/write through.

So the full path, when evil.com's JS does otherWindow.document.cookie, looks roughly like:

JS property access
   -> V8 binding
   -> BindingSecurity::ShouldAllowAccessTo
   -> SecurityOrigin::CanAccess
   -> IsSameOriginDomainWith  (+ agent cluster check)
   -> IsSameOriginWith        (tuple or nonce comparison)

Every single cross-window DOM access in Chromium funnels through this. THIS is the SOP INTERNALS in one call stack.

SOP INTERNALS: document.domain, the legacy footgun

Remember I said Netscape added document.domain in 1996 to relax the SOP on purpose? That's the domain_ field we saw earlier, and the reason CanAccess calls IsSameOriginDomainWith instead of the plain IsSameOriginWith.

The idea: two pages, catalog.example.com and orders.example.com, are not same-origin (different hosts). But if both of them run:

document.domain = "example.com";

...then the browser flips domain_was_set_in_dom_ = true on both, rewrites domain_ to "example.com", and now IsSameOriginDomainWith compares the domains instead of the full hosts, letting the two frames talk. Conceptually the check does something like this:

// src: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/weborigin/security_origin.cc
// Inside IsSameOriginDomainWith (simplified):
//
// Opaque origins never do domain relaxation, compare by nonce.
if (IsOpaque() || other->IsOpaque())
  return nonce_if_opaque_ == other->nonce_if_opaque_;

// The scheme still has to match.
if (protocol_ != other->protocol_)
  return false;

bool can_access = false;
if (!domain_was_set_in_dom_ && !other->domain_was_set_in_dom_) {
  // Neither touched document.domain: strict host + port.
  can_access = host_ == other->host_ && port_ == other->port_;
} else if (domain_was_set_in_dom_ && other->domain_was_set_in_dom_) {
  // BOTH opted in: compare the relaxed domain_ instead of host_.
  // Note: port is intentionally ignored in this branch.
  can_access = domain_ == other->domain_;
}
// If only ONE side set document.domain, access is denied.
return can_access;

Notice the crucial detail: both sides have to opt in by setting document.domain. If only one frame sets it, access is denied. And once you've set it, the port is ignored in that comparison branch, which is exactly the kind of weird edge behavior that made document.domain such a historically dangerous feature.

This mechanism is so footgun-y that Chrome has been actively working to disable document.domain-based relaxation by default, gating it behind Origin-Agent-Cluster headers. The modern web is trying to kill this 1996 feature, and the agent-cluster check we saw in CanAccess is part of that effort, it makes sure that even if you relax document.domain, you can't reach across an origin-isolated boundary.

SOP INTERNALS: opaque origins, nonces and precursors

Okay, now the part I find genuinely fascinating and where the real subtlety of the SOP INTERNALS lives: opaque origins.

A tuple origin is easy, it's just scheme+host+port. But what's the origin of:

None of these have a meaningful, trustworthy tuple. If Chromium just handed them (null, "", 0) and compared by tuple, then every sandboxed iframe would be same-origin with every other sandboxed iframe, which would be a catastrophe. So instead Chromium gives each of them an opaque origin: a fresh, globally-unique nonce_ (an UnguessableToken) that is same-origin with itself and nothing else.

// src: https://source.chromium.org/chromium/chromium/src/+/main:url/origin.h
// A Nonce wraps a lazily-initialized UnguessableToken. Two opaque
// origins are same-origin iff their tokens are equal, which only
// happens if one was copied from the other.
class Nonce {
  ...
  mutable base::UnguessableToken token_;
};

But here's the elegant part: an opaque origin doesn't fully forget where it came from. It keeps a precursor, the tuple origin it was derived from, stored in tuple_ (in url::Origin) or precursor_origin_ (in SecurityOrigin). This is why the accessor is literally named:

// src: https://source.chromium.org/chromium/chromium/src/+/main:url/origin.h
// For opaque origins, returns the *precursor* tuple (where it came
// from). For tuple origins, returns the tuple itself.
const SchemeHostPort& GetTupleOrPrecursorTupleIfOpaque() const {
  return tuple_;
}

bool opaque() const { return nonce_.has_value(); }

Why keep the precursor at all if opaque origins can't access anything? Because some downstream decisions (like blob URL handling, some CSP reporting, and debugging) need to know "this locked-down sandbox came from https://evil.com", even though it isn't allowed to act as https://evil.com. The precursor is memory, not authority. That distinction, memory of where you came from vs. the authority to act as it, is one of the most elegant ideas in the whole SOP INTERNALS.

How do you create one of these? Through the url::Origin factory methods:

// src: https://source.chromium.org/chromium/chromium/src/+/main:url/origin.cc
// Create() derives an origin from a URL. For non-standard / opaque
// cases it returns a fresh opaque origin (new nonce).
static Origin Create(const GURL& url);

// Resolve() is the inheritance-aware version: given a URL and a
// base_origin, it decides whether the result inherits base_origin
// (e.g. about:blank inheriting its opener) or becomes a fresh opaque
// origin carrying base_origin as its precursor (e.g. data: URLs).
static Origin Resolve(const GURL& url, const Origin& base_origin);

The difference between Create and Resolve is exactly the difference between "what origin does this URL have on its own" and "what origin does this URL get when loaded inside a document that already has an origin". about:blank is the classic example: on its own it's nothing, but loaded from https://a.com it inherits https://a.com's origin. That inheritance logic is pure SOP INTERNALS and it's the source of endless subtle security discussions.

SOP INTERNALS: agent clusters

I mentioned agent clusters in CanAccess, let me explain why they exist, because it's a modern (and important) layer.

Same-origin isn't only about "can I read your DOM". It's also about "can we share synchronous, mutable memory" — think SharedArrayBuffer, synchronous scripting, document.domain. Post-Spectre, the browser can't just let any two same-origin-looking documents share memory synchronously, because that opens timing side channels. So Chromium groups documents into agent clusters: sets of execution contexts that are allowed to synchronously script each other. Two documents can pass the tuple/domain check and still be denied access if they're in different agent clusters (for example when one has opted into origin isolation via Origin-Agent-Cluster).

That's the agent_cluster_id_ != other->agent_cluster_id_ branch we saw. It's the SOP evolving from a pure "scheme/host/port" rule into something that also accounts for process/memory isolation. The 1995 policy grew up hehehe.

SOP INTERNALS: why origin and not URL?

One thing I want to hammer, because it's a genuine, recurring source of real vulnerabilities in Chromium's own code: security decisions must be made on origins, not URLs. Chromium has an entire doc about this, and it's worth reading:

// src: https://source.chromium.org/chromium/chromium/src/+/main:docs/security/origin-vs-url.md
// "URLs are often not sufficient for security decisions, since the
//  origin may not be present in the URL (e.g. about:blank), may be
//  tricky to parse (e.g. blob: or filesystem: URLs), or may be
//  opaque despite a normal-looking URL."

Think about everything we just learned:

If some piece of C++ takes a GURL, chops off the path, and compares the resulting "origin-as-a-URL" string, it will get all of these cases wrong. That's why the doc calls using a GURL as an origin an anti-pattern, and why the correct code uses GetLastCommittedOrigin() on the browser side or GetSecurityOrigin() on the renderer side. A whole class of real Chromium security bugs is just "someone made a security decision on a URL string instead of a url::Origin". If you ever go hunting for logic bugs in the browser (like I like to do hehehe), origin-vs-URL confusion is a genuinely productive thing to look for.

Putting it all together

So let's zoom all the way back out. The Same Origin Policy, the thing that keeps evil.com out of your bank tab, is in Chromium ultimately just this:

  1. Every document gets an origin: either a tuple (scheme, host, port) or an opaque origin with a unique nonce and a memory of its precursor.
  2. Two representations of it, url::Origin (everywhere) and blink::SecurityOrigin (renderer), that are kept in agreement.
  3. When JS tries to reach across windows, BindingSecurity::ShouldAllowAccessTo calls SecurityOrigin::CanAccess.
  4. CanAccess stacks three gates: universal_access_ god-mode, then IsSameOriginDomainWith (which folds in the legacy document.domain relaxation), then the agent cluster check.
  5. Underneath it all, IsSameOriginWith does the timeless thing: tuple origins compare by scheme+host+port, opaque origins compare by nonce, and never the twain shall mix.

That's the whole origin story. A rule from 1995 that started as "don't let one script read another script's page", grown over 30 years into a layered system of tuples, nonces, precursors, domains, and agent clusters, all still enforcing that same simple, beautiful idea: your stuff is yours, and their stuff is theirs.

I didn't drop a bug in this one, but understanding the SOP INTERNALS this deeply is exactly the kind of foundation that lets you find bugs later, because now when you see some code make a security decision on a URL, or handle an opaque origin sloppily, or trust document.domain, a little alarm goes off in your head. And that alarm is worth more than any single CVE. :D

See you in the next one. Bye!


References