SecurityGENERALSPEC-EVOLVINGBROWSER-SPECIFICFRAMEWORK-SPECIFIC

Sanitization and Trusted HTML

Escaping and sanitization are different operations solving different problems — and when you genuinely must render HTML, allowlist it, at render time, with something you did not write.

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.

The question

I actually need to render HTML from an untrusted source. What is the correct way to do that?

The user intent

A person writes a formatted comment, or an editor publishes an article with headings, links and images. They expect the formatting to survive.

The obvious build

Write a function that removes <script> tags and on* attributes, run it once when the content is saved, and store the cleaned HTML.

Why it breaks

A hand-written filter is a denylist, and a denylist is a claim that you have enumerated every dangerous construct in a specification that is still growing. You have not, and neither has anyone else (Cross-Site Scripting).

How it breaks in a real browser
  • A hand-written filter is a denylist, and a denylist is a claim that you have enumerated every dangerous construct in a specification that is still growing. You have not, and neither has anyone else (Cross-Site Scripting).
  • Sanitizing at storage time means the stored value is a rendering decision. When a second consumer appears — an email digest, a mobile client, a plain-text export — it inherits a cleaning that was configured for a different context.
  • The stored value is also now un-reversible. If the allowlist was wrong, the original content is gone, and if it was too permissive, every existing row is already dangerous.
  • Escaping and sanitization get used interchangeably in review, and they are not related operations. One makes a value inert as text; the other parses markup and removes parts of it. Applying the wrong one produces either visible &lt;b&gt; or an actual vulnerability.
  • A sanitizer that strips attributes it does not recognise will strip role, aria-* and lang, quietly destroying the semantics that assistive technology depends on while the visual result looks unchanged (Semantics Before ARIA).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Escaping transforms a value so a specific parser treats it as data. It is per-context: HTML text, an attribute value, a URL component, a JavaScript string literal and a CSS value each have a different escaping. Applying the HTML one to a URL is not a partial defence, it is the wrong operation.
  • Sanitization parses a string as HTML into a tree, walks that tree, and removes elements, attributes and URL schemes that are not on an allowlist, then serialises what is left. It is only meaningful for markup, and it necessarily changes the content.
  • The framework does the escaping for you. {value} and textContent never reach the HTML parser at all, which is why they are safe by construction rather than by filtering (The DOM Is Not Your HTML).
  • An allowlist enumerates what is permitted and rejects everything else, so a new element or attribute in a future specification is excluded by default. A denylist admits it by default. That asymmetry is the entire argument.
  • Sanitizers must handle mutation: some strings parse differently the second time they are parsed, so a sanitizer that cleans, serialises and reparses can produce markup that the cleaning pass never saw. Maintained libraries defend against this class; hand-rolled ones do not know it exists.
  • Trusted Types, where the browser supports it, changes the shape of the problem: assigning a plain string to a sink like innerHTML throws, and only an object minted by a named policy is accepted. Enforcement moves from "did the developer remember" to the platform.
  • The `Sanitizer` API is the standardisation attempt for the parse-and-filter step itself, so that the allowlist lives in the browser rather than in a dependency you ship.

What this makes the browser do

And which of it is avoidable.

  • A parse of the untrusted string, a tree walk, and a serialisation — then a second parse when the result is assigned to innerHTML. Sanitizing is at minimum double the parsing work of rendering plain HTML (Tree Construction).
  • That work is on the main thread by default, and it scales with document size, so a long article sanitized on every render competes directly with input handling (What the Main Thread Owns).
  • A Trusted Types policy adds a function call per sink assignment — negligible — plus the cost of whatever the policy body actually does, which is usually the sanitizer.
  • The re-parse also rebuilds the subtree, discarding listeners, focus and accessibility state exactly as any innerHTML assignment does (Node Identity Across Updates).
  • Avoidable work: sanitizing inside the render function. Sanitize once when the content enters component state, memoise the result, and render the memoised value (Memoization).

Two operations that are not the same operation

Getting this distinction wrong is the most common conceptual error in the area, and it produces both of the visible failure modes: content that renders as literal &lt;b&gt;, and content that renders as an actual vulnerability. The two operations answer different questions.

Escaping asks: how do I make this value inert for the parser about to read it? Sanitization asks: this is markup, which parts of it am I willing to keep? Only the second one is allowed to change the content, and only the first one is context-dependent.

A comment body with formatting
One `clean()` at write time, then `innerHTML` everywhere
// on save
const clean = body.replace(/<script[\s\S]*?<\/script>/gi, '')
                  .replace(/ on\w+=/gi, ' ')
await db.comments.insert({ body: clean })

// on every surface, forever
el.innerHTML = comment.body
Store the original, sanitize at the surface that renders it
// on save: store what the user wrote
await db.comments.insert({ body })

// in the component, once per value rather than once per render
const html = useMemo(
  () => sanitize(comment.body, COMMENT_POLICY),   // explicit allowlist
  [comment.body],
)
return <div dangerouslySetInnerHTML={{ __html: html }} />

The first version is a denylist, so it is wrong by omission in ways nobody can enumerate; it destroys the original, so a wrong allowlist is unrecoverable; and it commits every future consumer — email, exports, a native client — to a cleaning configured for one HTML surface. The second keeps the source of truth intact and lets each surface apply its own policy, which is the only arrangement that survives a second consumer.

Deciding how much HTML you are actually signing up for

The choice is usually made implicitly by whoever built the first version of the editor, and it determines how much security and accessibility work the feature carries for the rest of its life. Making it explicitly is cheap and making it late is not.

How should untrusted formatted content be represented?

What is the smallest representation that meets the product requirement?

Plain text

when Names, titles, search queries, labels, anything with no formatting requirement. The default, and it should be fought for.

cost No formatting at all. Line breaks need CSS rather than markup (Semantics Are Behaviour).

A structured block model (JSON nodes you render)

when You control the editor and want formatting without an HTML sink anywhere in the pipeline.

cost You build the editor, the renderer and the schema migration story. Safe by construction, expensive to start.

Markdown with inline HTML disabled

when Developer-facing content, comments, documentation. A good ratio of expressiveness to risk.

cost You must actually disable HTML pass-through — most renderers allow it by default — and still allowlist link schemes yourself.

Sanitized HTML

when A rich-text editor or a CMS is a genuine requirement and content already exists as HTML.

cost A sanitizer dependency, an explicit allowlist including ARIA and lang, main-thread parse cost per render, and ongoing configuration review.

A sandboxed iframe

when Genuinely arbitrary third-party HTML — an ad, a preview of an untrusted document, a user-authored template.

cost A separate document: no shared styling, no shared focus order, postMessage for everything, and a real accessibility discontinuity (The Same-Origin Policy).

Trusted Types: moving enforcement into the platform

SPEC-EVOLVINGTrusted Types is available in Chromium and not in Safari at the time of writing, with Firefox partial; the policy-registration surface has already changed once, so treat both the availability and the exact API as things to verify rather than remember.

Sanitizing correctly is a discipline problem: the sanitizer works, and the bug is the one call site that skipped it. Trusted Types attacks that directly by making the sink itself refuse plain strings, so an unaudited assignment throws instead of executing — including inside dependencies you did not write.

The catch is availability. It is enforced in Chromium-based browsers and absent in Safari at the time of writing, so it belongs in a design as a layer that hardens most of your traffic rather than as the mechanism the feature depends on. Roll it out the way you roll out a policy: report-only first, read what it finds, then enforce.

A single named policy, with a fallback that still works
1// Enforced by a response header, alongside your CSP:
2// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html
3//
4// With that header, el.innerHTML = someString throws a TypeError.
5// Only an object minted by the 'app-html' policy is accepted.
6
7type HtmlSink = { toString(): string }
8
9const policy =
10 typeof window !== 'undefined' && 'trustedTypes' in window
11 ? window.trustedTypes.createPolicy('app-html', {
12 createHTML: (input: string) => sanitize(input, COMMENT_POLICY),
13 })
14 : undefined
15
16/** Returns a TrustedHTML where supported, a sanitized string where not. */
17export function toRenderableHtml(raw: string): HtmlSink | string {
18 return policy ? policy.createHTML(raw) : sanitize(raw, COMMENT_POLICY)
19}

The sanitizing still happens in both branches — the policy is a chokepoint, not a sanitizer. What the header buys you is that any *other* path to innerHTML, in your code or in a dependency, fails loudly instead of quietly working.

How to build it

Most important first.

  • First, ask whether you need HTML at all. A constrained block model, or markdown with inline HTML disabled, removes the sink instead of guarding it, and is a smaller long-term commitment (What a Component Owes Its Caller).
  • If you need it, use a maintained sanitizer with an explicit allowlist. Do not write one. This is the clearest case in frontend engineering for taking a dependency (Third-Party Scripts and the Supply Chain).
  • Sanitize where it is rendered, not where it is stored. Keep the original, apply the policy of the surface doing the rendering, and keep the option to change your mind.
  • Configure the allowlist explicitly and check it into review, rather than relying on a library default that can change across a major version. Include the ARIA and lang attributes you need, deliberately.
  • Allowlist URL schemes separately and narrowly — https, mailto, and whatever your product genuinely requires. data: and javascript: are not on that list.
  • Adopt Trusted Types where it is available, as a defence in depth with a graceful fallback. It converts an entire class of missed sink into a throw you can see (Frontend Error Tracking).
  • Pair it with a Content Security Policy. Sanitization is the first layer; the policy caps what a miss can do (Content Security Policy).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • This is the accessibility risk in this module. Sanitizers commonly strip attributes not on their allowlist, and role, aria-label, aria-labelledby, aria-describedby and lang are frequently not on it by default — so the content looks identical and is semantically empty (The Rules of ARIA).
  • Stripping lang from a quoted passage in another language makes a screen reader pronounce it with the wrong voice and phonetics, which is a real comprehension failure, not a cosmetic one (Internationalization).
  • Removing heading elements while keeping their visual styling collapses the document outline that screen-reader users navigate by, so a long article becomes one undifferentiated block (Document Structure and Reading Order).
  • Sanitizers also strip alt in some configurations, turning an informative image into an unlabelled one. Allowlist alt explicitly and treat a missing one as a content bug (Images, Video and the Elements That Own Their Layout).
  • Test the sanitizer output with the accessibility tree, not by eye. The visual diff after a configuration change is often empty while the semantic diff is large (Accessibility Testing).

What can go wrong

Failure modes
  • Configuration drift: a sanitizer configured once, then loosened during an incident to unbreak a customer's content, and never tightened.
  • A sanitizer that runs on the server and content that is then modified on the client — the guarantee applied to a different string from the one rendered (Hydration Mismatch).
  • Allowlisting style attributes without constraining them, which permits positioning and overlay tricks that are a UI redress problem rather than a script execution one (Clickjacking and Framing).
  • Allowlisting target="_blank" on links without rel="noopener", handing the opened page a reference back to yours in older engines.
  • Sanitizing and then re-inserting the result somewhere with a different parsing context — inside an SVG, or into an attribute — where the allowlist means something else.
  • The mitigation failing: Trusted Types enforced without a fallback path, so the feature simply throws for every user on a browser that does not support it.
What can arrive out of order
  • Content edited in one tab and rendered in another can be sanitized by two different versions of the configuration during a deploy, so the same document renders differently depending on which tab loaded when (Long-Lived Clients and Version Skew).
  • An async sanitizer — one running in a worker — can resolve after the component has moved to different content, so the result must be keyed to the input it was computed from (Out-of-Order Responses).
Security
  • Sanitization is a positive control with a well-understood failure mode: it is only as good as its allowlist and its handling of parser mutation, both of which are why maintained libraries exist.
  • It does not protect a URL sink. A sanitized document can still contain a link whose scheme you permitted, and a value you bind to href yourself never goes through the sanitizer at all (Cross-Site Scripting).
  • It does not make third-party HTML trustworthy in any broader sense: allowed images still leak a request to a third-party origin, and allowed iframes still embed something you do not control.
  • Trusted Types raises the floor considerably because it makes an unaudited sink assignment fail loudly rather than succeed silently. It does not validate what your policy chooses to return.
  • Attack technique against sanitizers — mutation, namespace confusion, parser differentials — belongs to Security Engineering; the frontend obligation is to not hand-roll the thing that has to withstand it.
Misreads
  • "Escaping and sanitizing are two words for the same thing." Escaping makes a value inert for one parser. Sanitizing parses markup and removes parts of it. They are not substitutes in either direction.
  • "We sanitize, so we do not need to escape." Sanitization applies to the HTML you deliberately render. Every other value on the page still needs to be text.
  • "Our regex handles it." Regular expressions cannot parse HTML, and the sanitizer's hard part is not finding tags — it is what the parser does with malformed input.
  • "Sanitizing once at save is more efficient." It is, and it fixes the output context at write time for every consumer that will ever exist.
  • "Trusted Types is the modern replacement for sanitizing." It is an enforcement mechanism for sinks. The policy you register still has to do the sanitizing.

Measuring it, and what changes in the field

How you would see this
  • Diff the sanitizer input against its output on real content in a test. What it removes is the interesting half, and it is the half nobody looks at (Component Testing).
  • Trusted Types violations, in report-only mode, enumerate every sink assignment in your application including the ones inside dependencies — usually a surprising list (Content Security Policy).
  • A performance profile of a content-heavy page shows sanitization as a discrete block of main-thread work, which tells you whether it belongs in render or in a memoised selector (Self Time, Total Time, and Where the CPU Went).
  • Automated accessibility checks over sanitized output catch stripped roles and missing labels that a visual regression test will report as identical (Accessibility Testing).
Slow device, slow network, large data, old tab
  • On large documents, sanitization cost is proportional to content size and lands squarely on the main thread; a worker is a legitimate option when the content is genuinely large (When a Worker Is Actually the Answer).
  • On a slow device, a per-keystroke preview that sanitizes on every change is a responsiveness problem masquerading as a security control (Interaction Responsiveness).
  • With server-rendered content, the sanitizer runs in an environment with a different DOM implementation, and parser differences between that implementation and the browser are exactly the gap this class of bug lives in (Server-Side Rendering).
  • In a browser without Trusted Types, everything falls back to discipline plus lint rules, which is why the fallback path has to be designed rather than discovered.
What this costs
  • A maintained sanitizer is bytes in the bundle and a dependency in your supply chain, for a guarantee you cannot reasonably produce yourself. Take the dependency and watch it (Dependency Security).
  • A restrictive allowlist breaks legitimate content and generates support tickets. A permissive one is a vulnerability with a good user-satisfaction score.
  • Sanitizing at render costs main-thread time per view; sanitizing at ingest costs a migration whenever the policy changes. Rendering-time is the safer default and the more expensive one.
  • Trusted Types is real defence in depth and requires auditing every sink in every dependency before it can be enforced, which is a project rather than a header.

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 allowlist-over-denylist argument, the escape-versus-sanitize distinction and the sanitize-at-render rule are properties of how parsers work, not of any one browser or library.
  • SPEC-EVOLVINGTrusted Types is enforced in Chromium and not implemented in Safari at the time of writing, with Firefox partial; anything depending on it needs a feature check and a working fallback, and the exact policy-registration surface has already changed once.
  • BROWSER-SPECIFICThe built-in Sanitizer API is shipping at different times and with different method names across engines, so a library sanitizer remains the portable choice while the native one is the direction of travel.
  • FRAMEWORK-SPECIFICAngular sanitizes values bound to [innerHTML] through its own sanitizer unless you explicitly bypass it, whereas React's dangerouslySetInnerHTML and Svelte's {@html} do no sanitization at all — so "the framework handles it" is true for exactly one of them.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — modelling formatted content as a schema rather than a string, and why the representation choice outlives every renderer built on top of it.