HTML in Markdown and Safety
Mixing raw HTML into Markdown, why renderers often escape or strip it, and how to render Markdown from users safely without opening the door to cross-site scripting.
Raw HTML in Markdown
Markdown was designed as a writing format for the web, with HTML as the escape hatch: for anything the syntax does not cover, you write the HTML tag directly. Raw HTML is HTML written inside a Markdown document, which the renderer copies into the output as it is instead of converting it. CommonMark defines it in two forms: inline HTML inside a paragraph, and HTML blocks (below).
Typical, legitimate uses in documentation:
-
<kbd>for keys: press Ctrl+C; -
<sup>and<sub>for units and formulas: m3, CO2; -
<details>and<summary>for a collapsible section with a long log or an optional explanation; -
<br>for a line break inside a table cell, and<!-- … -->for a comment that readers should not see.
Whether raw HTML survives depends on the renderer. markdown-it,
which renders the examples on this site, has an
html option that is off by default. With the option
off, the tags are escaped: shown as text instead of
interpreted.
Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the trace.
<p>Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the trace.</p>
The next example turns the html option on, and the tags
pass through:
Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the trace.
<p>Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the trace.</p>
For how the HTML elements themselves work, see Inline text elements in the HTML & CSS topic.
HTML blocks
An HTML block is a run of lines
that starts with an HTML tag (or a comment, or a few other
constructs) at the beginning of a line and is passed through as raw
HTML. CommonMark defines seven kinds by how they start, for example
<script, <pre or
<style, a comment <!--, a known
block element such as <div> or
<details>, or any other complete tag on its own
line. Most kinds end at the first blank line.
The rule that surprises people:
inside an HTML block, Markdown is not processed
until the block ends. In this collapsible log, the line with
asterisks is not turned into bold text, because it is still part of
the HTML block (the html option is on in this and the
next example):
<details>
<summary>Full error log</summary>
**Connection refused** on port 443
</details>
<details> <summary>Full error log</summary> **Connection refused** on port 443 </details>
Blank lines end the HTML block, so the Markdown in between is parsed again. The preview starts collapsed; click the summary to open it:
<details>
<summary>Full error log</summary>
**Connection refused** on port 443
</details>
<details> <summary>Full error log</summary> <p><strong>Connection refused</strong> on port 443</p> </details>
Why renderers escape or strip HTML
Raw HTML is the most powerful part of Markdown, and therefore the most dangerous. Renderers and platforms take different positions, and you need to know which one applies before you rely on a tag:
| Renderer or platform | Raw HTML by default |
|---|---|
| CommonMark spec | Passed through (the spec defines the syntax, not a security policy) |
| markdown-it |
Escaped (option html: false); its own
documentation calls this safe without a sanitizer
|
| cmark (C reference implementation) | Removed, together with dangerous links, unless the "unsafe" option is set |
| commonmark.js | Passed through; a safe option removes it |
| GFM spec |
Passed through, except nine tags
(<script>, <iframe>,
<style>,
<textarea> and others) whose
< is escaped
|
| GitHub.com |
Rendered, then sanitized: script tags, inline styles and
class and id attributes are
removed
|
So <details>, <kbd>,
<sup> and comments work in a GitHub README, but a
style="color: red" or a class for your own
CSS silently disappears. The GFM "disallowed raw HTML" rule, also
called the tagfilter, is not a sanitizer: it only neutralizes nine
tags and leaves every other tag and attribute alone. GitHub's real
protection is the separate sanitizing step.
Escaping also changes how comments behave. With raw HTML off, a comment you meant to hide shows up on the page:
<!-- TODO: add the new VLAN before the change on Friday -->
The access switches use VLAN 20 for voice.
<p><!-- TODO: add the new VLAN before the change on Friday --> The access switches use VLAN 20 for voice.</p>
With the html option on, the comment is an HTML block
and the browser hides it:
<!-- TODO: add the new VLAN before the change on Friday -->
The access switches use VLAN 20 for voice.
<!-- TODO: add the new VLAN before the change on Friday --> <p>The access switches use VLAN 20 for voice.</p>
The risk: Markdown from other people
Rendering your own README is not dangerous: you trust its author. The risk appears when an application renders Markdown written by someone else and shows the result to other people: comments on a portal, ticket descriptions in a service desk tool, wiki pages, user profiles, chat messages, and increasingly answers from AI assistants, whose output can be steered by text an attacker planted (see prompt injection).
If raw HTML passes through, every reader's browser runs whatever the
author wrote. A <script> element or an event
handler attribute in a comment would run with the reader's session
on that site:
cross-site scripting
(XSS), stored in the database and served to everyone who opens the
page. "Markdown is just text" is no protection, because the Markdown
becomes HTML in the end.
With markdown-it's defaults, a harmless test script is shown as text and never runs:
Nice update! <script>alert("test")</script>
<p>Nice update! <script>alert("test")</script></p>
Unsafe links
Turning HTML off is not enough on its own, because a normal Markdown
link also ends up as an href. An
unsafe link scheme is a
URL scheme
that makes the browser do something other than navigate to a page
when the link is clicked: javascript: and
vbscript: run code, data: can carry a
complete HTML document, and file: points into the
reader's own file system.
A safe renderer checks every link and image address. markdown-it
refuses javascript:, vbscript:,
file: and data: URLs, except
data: images in GIF, PNG, JPEG or WebP format, and the
check is not fooled by capital letters. A refused link stays visible
as plain text:
[Open the portal](javascript:alert(1))
[Status page](https://status.example.com)
[Shared drive](file:///S:/runbooks)
<p>[Open the portal](javascript:alert(1))</p> <p><a href="https://status.example.com">Status page</a></p> <p>[Shared drive](file:///S:/runbooks)</p>
cmark and commonmark.js (with its safe option) refuse
the same schemes. If you write your own renderer, template or plugin
that builds links, check the scheme with an allowlist
(https:, http:, mailto:,
relative paths) rather than trying to list the bad ones; see
Checking URLs
in the JavaScript topic.
Sanitizing the output
Sometimes raw HTML is needed for user content, for example a
knowledge base where editors use <details> and
<kbd>. Then the rendered HTML must go through an
HTML sanitizer: a library that
parses HTML and removes every element, attribute and URL that is not
explicitly permitted, leaving harmless markup behind. The OWASP
Cross Site Scripting Prevention Cheat Sheet recommends DOMPurify
(version 3.4.16 as of October 2026) for this.
Allowlist, not denylist
A good sanitizer works with an
allowlist: a list of the
tags, attributes and URL schemes that are allowed, such as
p, a with href,
code, table, details,
kbd. Everything else is removed. The opposite approach,
a denylist of known-bad things (<script>,
onclick, javascript:), fails as soon as an
attacker finds a tag, attribute or spelling that is not on the list,
and HTML offers many of those.
Why sanitize the output and not the Markdown source? Because the source is not HTML yet. Character references, link syntax and the renderer's own rules can turn harmless-looking text into markup during conversion. Only the final HTML is what the browser will see, so that is what must be checked.
In browser code, the order of operations looks like this:
import markdownit from "markdown-it";
import DOMPurify from "dompurify";
const md = markdownit({ html: true }); // raw HTML wanted here
const rendered = md.render(commentText); // untrusted input
const clean = DOMPurify.sanitize(rendered); // allowlist sanitizer
preview.innerHTML = clean; // nothing changes clean after this
- Sanitize last. Do not modify the HTML after sanitizing it; OWASP warns that later changes can undo the sanitizer's work.
- Keep the sanitizer and the parser updated. OWASP notes that sanitizer bypasses are discovered regularly. markdown-it shipped several security releases in 2026 (versions 14.1.1, 14.2.0, 15.0.1 and 15.0.2 fixed denial-of-service problems with slow input), and Material for MkDocs 9.7.7 (July 2026) fixed an XSS flaw in its search suggestions.
- Sanitize on output, validate on input. Input validation (length limits, allowed characters for fields such as names) is still useful, but it does not replace sanitizing the rendered HTML.
More layers
Security works best as defense in depth. Around the renderer and the sanitizer:
- Content Security Policy. A CSP header that forbids inline scripts stops many XSS attacks even if something slips through.
-
Trusted Types. In supporting browsers, a CSP that
requires
Trusted Types
makes
innerHTMLrefuse plain strings, so every insertion has to pass through a sanitizer policy. -
Sandboxed previews. A live preview can be
rendered in an
<iframe sandbox>without scripts. The previews on this site work that way. -
Prefixed heading anchors. Plugins that create
idattributes from user headings can let a user's heading collide with names the page's own scripts use (DOM clobbering). markdown-it's security notes advise adding a prefix to generated ids.
In practice
| Situation | Raw HTML | What to do |
|---|---|---|
| Your own README or docs in a repository | Fine, sparingly | Use tags that survive on the target platform; leave blank lines around HTML blocks |
| Comments, tickets, profiles from users | Off | Use the renderer's safe defaults; check that unsafe links are refused |
| User content that needs some HTML | On, sanitized | Allowlist sanitizer on the rendered HTML, CSP, regular updates |
| Markdown from an AI assistant shown in your app | Off | Treat it as untrusted user input, like any other |