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.

{ "elements": [ { "id": "source", "label": "Untrusted Source" }, { "id": "transform", "label": "Transform / Validate" }, { "id": "sink", "label": "DOM Sink" }, { "id": "outcome", "label": "Outcome" } ], "connections": [ { "from": "source", "to": "transform" }, { "from": "transform", "to": "sink" }, { "from": "sink", "to": "outcome" } ] }

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.

{ "elements": [ { "id": "message", "label": "postMessage Received" }, { "id": "origin-check", "label": "Origin Check?", "shape": "decision" }, { "id": "reject", "label": "Rejected" }, { "id": "handle", "label": "Handled" } ], "connections": [ { "from": "message", "to": "origin-check" }, { "from": "origin-check", "to": "reject", "label": "no" }, { "from": "origin-check", "to": "handle", "label": "yes" } ] }

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.

Controls in place

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:

{ "elements": [ { "id": "browser", "label": "Browser: origin rules, DOM parser, CSP, Trusted Types" }, { "id": "framework", "label": "Framework: rendering, text escaping, lifecycle" }, { "id": "application", "label": "Application: validation, sanitization, authorization" }, { "id": "conclusion", "label": "Every Layer Has To Do Its Part" } ], "connections": [ { "from": "browser", "to": "conclusion" }, { "from": "framework", "to": "conclusion" }, { "from": "application", "to": "conclusion" } ] }

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.