The npm showdown package converts Markdown to HTML and deliberately does not sanitize its output, so rendering untrusted Markdown with Showdown and inserting the result into the DOM is a cross-site scripting vulnerability unless you sanitize the HTML afterward. This is not a bug — the maintainers document it plainly in the project wiki — but it is one of the most reliably misunderstood contracts in the frontend ecosystem. This guide covers why Markdown converters are XSS machines by nature, what Showdown's options do and do not protect against, and the output-sanitization pattern that actually works.
Why Markdown rendering is an XSS problem at all
Markdown looks safe because it looks like plain text. It is not. The original Markdown spec makes raw HTML a first-class feature: any HTML embedded in the source passes through to the output. That means this "Markdown" comment is a working payload the moment it hits innerHTML:
Nice article! <img src=x onerror="alert(document.cookie)">
Even without raw HTML passthrough, Markdown syntax itself generates attributes from user input. Link destinations become href values ([click](javascript:alert(1)) is the classic), image sources become src, and titles become quoted attributes — every one an injection point if escaping slips anywhere. This is a property of the format, not of Showdown specifically; marked, markdown-it, and the rest of the family all face it, and several have shipped XSS CVEs over the years for exactly these paths.
Showdown's position, stated in its wiki page on Markdown's XSS vulnerability, is that sanitizing input Markdown is unreliable and that the correct approach is to filter the output HTML. That stance is correct — and it moves the responsibility onto you.
What showdown npm gives you, and the trust contract
Showdown is one of the longest-lived JavaScript Markdown converters, works in the browser and Node.js, and is configurable through options and "flavors" (its github flavor enables tables, task lists, strikethrough, and more):
import showdown from "showdown";
const converter = new showdown.Converter();
converter.setFlavor("github");
const html = converter.makeHtml(userSubmittedMarkdown);
// html is UNTRUSTED. Do not innerHTML this yet.
The trust contract in one line: Markdown in, attacker-controlled HTML out. Every downstream decision follows from treating makeHtml() output as tainted.
Two Showdown-specific notes. First, more enabled features means more generated HTML shapes — tables, header IDs and anchors, task-list inputs — and each generated attribute is surface where an escaping bug would land, which is an argument for enabling only the syntax you need when input is untrusted. Second, Showdown has historically had escaping issues reported against specific sub-features like any parser of its age; tracking the package in your vulnerability feed matters more for a converter than for an average utility, because new advisories translate directly into stored XSS in your app.
safeMode and option flags are not sanitizers
Showdown exposes knobs that sound like security controls, and it is important to be precise about what they cover. Disabling raw HTML passthrough or enabling escaping options addresses the embedded-HTML vector but not necessarily every attribute-generation path, and older writeups have repeatedly shown converter-level "safe modes" across the ecosystem to be narrower than users assume. The maintainers themselves point users to output filtering rather than claiming the options make untrusted input safe.
Treat converter options as defense-in-depth that reduces surface, never as the control that lets you skip sanitization.
The pattern that works: sanitize the output with DOMPurify
The industry-standard fix is to run the generated HTML through a real HTML sanitizer with an allowlist. DOMPurify is the usual choice:
import showdown from "showdown";
import DOMPurify from "dompurify";
const converter = new showdown.Converter();
converter.setFlavor("github");
export function renderMarkdown(md) {
const dirty = converter.makeHtml(md);
return DOMPurify.sanitize(dirty, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["style", "form", "input"],
FORBID_ATTR: ["onerror", "onload"],
});
}
container.innerHTML = renderMarkdown(comment);
Why output sanitization is the right layer: DOMPurify parses the HTML the way a browser will, then removes anything outside its allowlist — script elements, event handler attributes, javascript: URLs, SVG tricks — regardless of which converter feature produced them. It does not care whether the payload arrived through raw HTML passthrough, a table header, or a link title. That converter-independence is what makes the pattern durable across Showdown upgrades and advisories.
On the server (Node.js SSR, generating emails or PDFs), pair DOMPurify with jsdom, or use a server-side sanitizer like sanitize-html. Sanitize at render boundary, after the final HTML exists — sanitizing Markdown text before conversion is exactly the unreliable approach the Showdown wiki warns against.
Defense in depth around the renderer
Sanitization is the control; these reduce blast radius when something slips:
- Content Security Policy. A CSP without
unsafe-inlinescript sources turns many injected-markup bugs into non-events. It is your safety net for the sanitizer bypass you have not heard about yet. - Constrain link protocols. Allow
https:,http:, andmailto:inhref; DOMPurify handlesjavascript:by default, but an explicit allowlist documents intent. - Render user content in the least privileged context you can. Untrusted rich content inside a sandboxed iframe with a separate origin removes session cookies and DOM access from the equation entirely — heavyweight, but right for things like user-authored widgets.
- Test it from the outside. Stored XSS through a Markdown field is precisely the class of bug a DAST scanner finds by submitting payloads through your real comment form and observing the rendered page. Static review tells you the sanitizer exists; dynamic testing tells you it is actually in the path.
Keep an eye on the supply chain angle too: your protection is only as current as your resolved versions of both showdown and DOMPurify, and sanitizer bypasses get patched regularly. An SCA tool such as Safeguard alerting on either package is effectively an XSS early-warning system for every feature that renders Markdown.
Reviewing existing code for this bug
The audit is mechanical and worth an hour. Grep for makeHtml (and dangerouslySetInnerHTML, innerHTML, v-html in the same files). For each hit, answer two questions: can any part of the input originate from a user other than the viewer, and is there a sanitizer between conversion and insertion? "The Markdown only comes from our CMS" deserves scrutiny — CMS accounts get phished, and stored XSS written by one low-privilege author executes in an admin's session. The secure-rendering exercises in Safeguard Academy include a lab version of exactly this review if your team wants a practice run.
FAQ
Does showdown sanitize HTML output?
No. Showdown converts Markdown to HTML without sanitizing it, and the maintainers document this as a deliberate design decision. Untrusted input must be sanitized after conversion, typically with DOMPurify, before the HTML reaches the DOM.
Is npm showdown safe to use in 2025?
Yes, when used per its contract: treat its output as untrusted HTML and sanitize at the render boundary. The package remains widely used; the risk is architectural misuse (inserting unsanitized output) rather than the library's existence in your tree.
Why not just sanitize the Markdown input instead?
Because Markdown's own syntax generates HTML attributes and URLs, input filtering cannot anticipate every way text becomes markup. The Showdown wiki itself recommends filtering the output HTML, where an allowlist sanitizer sees exactly what the browser will parse.
Do I still need a sanitizer if I disable raw HTML in the converter?
Yes. Disabling HTML passthrough removes one vector, but converter-generated markup (links, images, tables, header anchors) still derives attributes from user input. Output sanitization covers all vectors regardless of converter configuration.