A Markdown parser can produce exactly the right HTML and still leave your application exposed to XSS. Parsing answers what the input means. Sanitization decides which parts of that meaning are allowed to reach an HTML sink.
I tested that boundary with Node.js 25.3.0, Marked 18.0.7, DOMPurify 3.4.12, and jsdom 30.0.1. The important comparison is not a screenshot. It is the HTML before and after sanitization.
The smallest useful pipeline
const rendered = marked.parse(markdown)
const sanitized = DOMPurify.sanitize(rendered, {
USE_PROFILES: { html: true },
SANITIZE_NAMED_PROPS: true,
})
This deliberately keeps two responsibilities separate. Marked parses Markdown. DOMPurify applies an allow-list to the HTML structure that will approach the browser.
Five cases that expose the boundary
1. Normal content survives
# Hello
[Safe](https://example.com)
The heading and HTTPS link survive both stages. A sanitizer should preserve allowed document structure, not flatten every document to text.
2. Raw HTML is valid Markdown, not necessarily safe HTML
<img src=x onerror="alert(1)">
Marked 18.0.7 returns the element and its event attribute unchanged. DOMPurify returns:
<img src="x">
The parser did not fail. CommonMark supports raw HTML. The unsafe step would be treating syntactic validity as authorization to insert every attribute.
3. URL schemes need their own policy
[click](javascript:alert(1))
Rendered HTML:
<p><a href="javascript:alert(1)">click</a></p>
Sanitized HTML:
<p><a>click</a></p>
An element allow-list alone is insufficient. URL-bearing attributes need scheme validation.
4. Code examples must not be cleaned as attacks
```html
<img src=x onerror="alert(1)">
```
The parser escapes the payload inside pre > code. A regex that removes attack-looking source before parsing would damage legitimate security documentation. Context has to be established first.
5. XSS defenses extend beyond script
<form id="attributes"><input name="action"></form>
With SANITIZE_NAMED_PROPS, the result becomes:
<form id="user-content-attributes"><input name="user-content-action"></form>
This addresses DOM clobbering: attacker-controlled names can interfere with properties that application code expects to resolve normally.
Put sanitization after the last unsafe transform
A practical pipeline is:
untrusted Markdown
-> parser
-> Markdown/HTML AST transforms
-> sanitizer
-> serializer
-> matching HTML sink
The rehype-sanitize documentation makes the ordering rule explicit: sanitize after the last unsafe operation, because a later plugin can reintroduce unsafe properties. DOMPurify's current threat model adds another constraint: do not sanitize and then freely post-process the result. The policy and the sink must stay aligned.
Turning off raw HTML is a useful reduction in attack surface, but it is not a universal sanitizer. Plugins, link protocols, generated IDs, and later transforms still deserve explicit policies.
What I verify in a conversion workflow
When checking Markdown to HTML, I separate three questions:
- Did normal Markdown preserve the intended structure?
- Did fenced examples remain inert code?
- Is untrusted output safe for this application's sink and policy?
The first two are conversion checks. The third belongs to the embedding application. A converter producing structurally correct HTML does not automatically promise that arbitrary input is safe to inject into another site's DOM.
Engineering checklist
- Treat external Markdown as untrusted by default.
- Disable raw HTML where the product does not need it.
- Sanitize the final HTML tree with a maintained allow-list sanitizer.
- Model URL schemes,
id,name, and styling capabilities explicitly. - Do not run arbitrary HTML-mutating plugins after sanitization.
- Test AST shape, rendered HTML, and sanitized HTML separately.
- Pin and update sanitizer versions; security fixes are part of the boundary.
The design question I keep coming back to is this: should Markdown renderers disable raw HTML by default, or should they expose it only behind an explicit host-supplied security policy?
Top comments (0)