Blog
DOM Security: What the Browser Actually Exposes, Where the Real Attack Surfaces Are, and What Frameworks Do and Do Not Protect
Date TBD
Introduction
I have been working with JavaScript since 1998. I built web experiences before modern template engines, build systems, component frameworks, dependency managers, and browser tooling became the default way developers interact with the web platform. I've written the HTML, CSS, JavaScript, event systems, network code, and cross-browser compatibility layers by hand, more than once.
One pattern I've noticed since: developers are frequently taught framework rules
before they're taught browser security boundaries. "Never use innerHTML"
gets repeated as a rule to memorize, without the underlying reason ever quite landing
— which means the rule doesn't generalize to the next sink, the next framework,
or the next escape hatch. Security isn't provided by syntax. It comes from
understanding trust boundaries, execution contexts, parsing behavior, and what
authority the resulting action actually has.
What I care about: show me where the string came from, where it goes, what parses it, and what authority the resulting action has.
Framework abstraction does not erase browser behavior. If the browser eventually receives HTML, a URL, a script, or a privileged request, the security semantics of that operation still apply — no matter how many layers of framework sit on top of it.
Sources, Transforms, and Sinks
Almost every DOM security question reduces to the same three-part shape: where did
this value come from (a URL, an API response, a postMessage, a
third-party script), what — if anything — validated or sanitized it along
the way, and what DOM operation finally consumes it. The sink is what actually
determines the danger. A source, by itself, is neither safe nor unsafe.
Text vs. Markup
The single most consequential decision in this whole article is which of two DOM
properties you assign an untrusted string to. textContent never parses
its input as markup — angle brackets are just characters. innerHTML
parses its input as HTML, full stop, which means anything shaped like a script-bearing
element executes. This isn't a framework distinction. Every framework built on top of
the DOM eventually makes this same choice on your behalf, somewhere underneath its own
abstraction.
URLs Are Their Own Security Context
A URL isn't just a string — it's an instruction with a protocol, and
javascript: is a valid one. Assigning an untrusted string directly to
href, an iframe's src, or a
fetch() target treats "URL-shaped data" as automatically safe, which it
isn't. The fix isn't complicated: parse it, check the protocol against an allowlist,
and only then assign it.
Messaging Boundaries
postMessage deserves its own section because the failure mode is so easy
to miss: a handler with no origin check accepts a message from
any origin, including an attacker's iframe embedded on a completely unrelated
page. This is a trust-boundary problem, not a sink problem — it doesn't matter
how carefully the message payload is handled downstream if the handler never checked
where the message came from in the first place.
Sanitization and Browser Policy
When you genuinely need to render markup — a rich-text editor's output, for
instance — sanitization strips the dangerous parts before they reach
innerHTML. Trusted Types goes a step further: it makes the browser reject
any string assigned to a dangerous sink unless it was produced by an explicit,
policy-approved function, which closes off the entire category of "someone forgot to
sanitize this one call site." A Content Security Policy is a third, independent layer
— it can block inline script execution even after unsafe markup has already been
written to the DOM. None of these three replace each other; they cover different
failure points in the same pipeline.
The Interactive Security Explorer
This is the same source/transform/sink model from earlier, made interactive. Pick a source, a sink, and toggle whichever controls you want to test — the outcome below is computed from a fixed rule table, the same rules described in this article, not a live security scan. Nothing here executes real markup, makes a real network request, or touches the live page: this explorer teaches the decision, it doesn't host a working exploit on the article itself.
Outcome
What Frameworks Genuinely Improve, and What They Cannot Solve
Frameworks earn their keep here in a narrow but real way: JSX and equivalent
templating systems default to text-escaping, so the common case — render this
variable into the page — is safe by default in a way raw
innerHTML never was. That's a genuine improvement, not marketing.
What frameworks cannot do is decide your trust boundaries for you. A component is not automatically a security boundary, a state architecture, or a domain boundary. React is not a browser security boundary — it's an implementation running inside the browser, subject to the exact same origin rules, parsing behavior, and policy enforcement as anything else. If your application still has an explicit "render raw HTML" escape hatch (and most component frameworks have one), the framework has only moved the decision point, not made it for you.
Build the Threat Model First
Every layer here does part of the job, and none of them does all of it:
Start with the browser. Draw the trust boundary. Follow the data to the sink. Apply
the control that matches the actual execution context. Then evaluate what the
framework helps with, and what it leaves entirely in your hands. I don't want anyone
memorizing "never use innerHTML" as a rule — the actually useful
lesson is understanding what innerHTML causes the browser to do, because
that lesson transfers to every sink you haven't encountered yet.