I’m adding AI-generated summaries and help text to a web app. Some responses include Markdown, and a model could also return HTML-like text if a user asks for it. What’s the safe way to display that content without turning a prompt or model mistake into a cross-site scripting vulnerability?
The key is to treat untrusted model output like any other untrusted input. A model may produce harmless formatting most of the time, but it is not a security boundary. It can echo user-supplied content, produce unexpected markup, or be steered into generating tags and attributes that a browser interprets as active content.
Choose the rendering path based on what the UI needs
If the interface only needs plain text, render the response as text. Frameworks such as React escape text inserted into normal JSX by default, so a string containing <script> is displayed rather than executed. Avoid bypassing that protection by placing the response into an HTML-injection API.
If the interface needs Markdown formatting, use a Markdown rendering pipeline and sanitize the resulting HTML before it reaches the page. Markdown is a format, not a safety guarantee: some parsers allow raw HTML, and links or images can introduce risky URL schemes. Sanitizing only the original Markdown text, before conversion, is not enough because the converter creates the HTML that the browser will parse.
Sanitize after converting Markdown
For a browser-side JavaScript application, the general order is: convert the model response to HTML, then pass that HTML through a maintained sanitizer with a narrow allowlist, and only then insert the sanitized result. For example, with Marked and DOMPurify:
function renderModelMarkdown(untrustedText) {
// Convert Markdown first; the output is still untrusted HTML.
const html = marked.parse(untrustedText, {
gfm: true
});
// Keep only the tags and attributes this UI needs.
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
"p", "br", "strong", "em", "ul", "ol", "li",
"blockquote", "code", "pre", "a"
],
ALLOWED_ATTR: ["href", "title"]
});
}
// Insert only the sanitized string, never the original model response.
container.innerHTML = renderModelMarkdown(modelResponse);
This is an example policy, not a drop-in configuration for every app. If the product does not need links, remove a and its attributes. If it needs headings or tables, add only the specific elements required and test the result. DOMPurify also applies protections to dangerous URL values by default; keep its URI handling enabled rather than weakening it to accommodate unusual input.
Watch for the easy-to-miss cases
- Do not trust the prompt to enforce safety. Instructions such as “never return HTML” can improve output quality, but they do not replace validation or sanitization.
- Do not insert raw HTML from a model. APIs such as
innerHTMLand framework escape hatches should receive sanitized output only. - Validate links separately when the product has stricter rules. Sanitizers block many dangerous schemes, but an app may also want to allow only HTTPS links or restrict destinations to an approved set of domains. That is URL scheme validation and application policy, not a job for the prompt.
- Keep the sanitizer current. Use a maintained library and update it as part of normal dependency maintenance. A custom collection of regular expressions is not a reliable replacement for HTML sanitization.
Also consider where the rendering happens. If Markdown is converted on the server, sanitize there before sending HTML to the browser; if it is converted in the browser, sanitize there before insertion. In either case, avoid a second transformation after sanitization that could introduce new markup. A content security policy is useful as another layer of defense, but it should not be treated as permission to render unsanitized content.
For a basic chat transcript, plain-text rendering is often the safest and simplest choice. Add Markdown only when it genuinely improves the experience, keep the allowed formatting small, and make the application—not the LLM—responsible for deciding what the browser is allowed to interpret.