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 &lt;kbd&gt;Ctrl&lt;/kbd&gt;+&lt;kbd&gt;C&lt;/kbd&gt; 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>&lt;!-- TODO: add the new VLAN before the change on Friday --&gt;
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! &lt;script&gt;alert(&quot;test&quot;)&lt;/script&gt;</p>

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.

A safe pipeline for Markdown from users. Untrusted Markdown goes to a parser with raw HTML off and a link check. The parser produces an HTML string. If HTML is allowed, an allowlist sanitizer cleans that string. The cleaned HTML is shown on a page protected by a Content Security Policy. Markdown untrusted Parser html: false link check HTML a string Sanitizer allowlist Page + CSP Sanitize the HTML that comes out of the parser, never the Markdown that goes in.
Each step catches what the previous one might miss; with raw HTML off, the sanitizer is an extra layer rather than the only one.

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 innerHTML refuse 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 id attributes 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