HTMX is great at making server-rendered apps feel fast without dragging in a giant frontend stack. I like it for exactly that reason. You keep your templates, keep your backend routing, and sprinkle interactivity where you need it.
The catch: HTMX is built around fetching HTML and swapping it into the DOM. That’s the same territory where XSS thrives.
If your app sends attacker-controlled HTML back to the browser, HTMX will happily insert it. That doesn’t make HTMX uniquely insecure. It just means the trust boundary is very clear: HTMX amplifies whatever your server returns.
Here’s how XSS happens in HTMX apps, where developers usually get burned, and what to do instead.
Why HTMX changes the XSS threat model
Classic server-rendered apps mostly render a full page on the server. HTMX encourages smaller HTML fragments like this:
<form hx-post="/comments" hx-target="#comments" hx-swap="beforeend">
<input name="text" />
<button type="submit">Post</button>
</form>
<div id="comments"></div>
The server might return:
<div class="comment">Hello world</div>
HTMX inserts that into #comments.
If the server returns this instead:
<div class="comment"><script>alert(1)</script></div>
you’ve got a problem. Depending on how the fragment is inserted and what else is in it, you may trigger script execution or other dangerous behavior through event handlers, javascript: URLs, SVG payloads, malicious attributes, or later DOM processing.
The core rule is simple:
Treat every HTMX response as untrusted unless it was generated from safe templates with properly escaped data.
The most common HTMX XSS bug
The biggest mistake I see is returning raw user input inside HTML fragments.
Vulnerable server code
Node/Express example:
import express from "express";
const app = express();
app.use(express.urlencoded({ extended: false }));
const comments = [];
app.post("/comments", (req, res) => {
const text = req.body.text;
comments.push(text);
// Vulnerable: raw HTML response with unescaped user input
res.send(`<div class="comment">${text}</div>`);
});
app.listen(3000);
And the HTMX form:
<form hx-post="/comments" hx-target="#comments" hx-swap="beforeend">
<input name="text" />
<button type="submit">Post</button>
</form>
<div id="comments"></div>
If someone submits:
<img src=x onerror=alert(document.domain)>
your app returns:
<div class="comment"><img src=x onerror=alert(document.domain)></div>
That’s stored or reflected XSS, depending on your flow.
Safer version
Escape user content before placing it into HTML.
import express from "express";
const app = express();
app.use(express.urlencoded({ extended: false }));
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
app.post("/comments", (req, res) => {
const safeText = escapeHtml(req.body.text);
res.send(`<div class="comment">${safeText}</div>`);
});
app.listen(3000);
Better yet, render a server-side template and let the template engine escape by default.
Template engines help, until you bypass them
Most server-side template engines do the right thing by default when outputting text into HTML body context.
Example with a template:
<div class="comment">{{ text }}</div>
If text is escaped by default, you’re fine.
But developers often punch straight through the protection with “raw HTML” features:
- Handlebars: triple braces
{{{text}}} - Twig:
|raw - Jinja2:
|safe - many others have some equivalent
That’s where HTMX apps get messy. Someone wants to support “rich text comments” or “admin-customized snippets,” flips on raw rendering, and now every HTMX endpoint is a script delivery system.
If you really need user-authored HTML, sanitize it on the server with a proper HTML sanitizer and still lock things down with CSP. More on CSP later.
Dangerous contexts inside fragments
Escaping for HTML text content is not enough in every context. Context matters.
HTML body context
Usually safe with standard escaping:
<div>{{ text }}</div>
Attribute context
Also needs proper escaping, and ideally validation:
<div data-name="{{ text }}"></div>
If you build attributes manually, you can break out:
res.send(`<div data-name="${req.body.text}"></div>`);
Payload:
" onmouseover="alert(1)
Result:
<div data-name="" onmouseover="alert(1)"></div>
URL context
This one gets abused constantly:
<a href="{{ user_url }}">Profile</a>
Escaping alone does not stop javascript:alert(1).
You need allowlist validation:
function safeUrl(input) {
try {
const url = new URL(input, "https://example.com");
if (url.protocol === "http:" || url.protocol === "https:") {
return url.href;
}
} catch {}
return "#";
}
Inline JavaScript context
Don’t do this:
<div hx-get="/search?q={{ query }}"></div>
<script>
const q = "{{ query }}";
</script>
JavaScript string escaping is different from HTML escaping. This is exactly how weird quote-breaking XSS bugs survive code review.
My opinion: avoid inline scripts entirely in HTMX apps. It pairs nicely with a strict CSP.
HTMX-specific places developers get sloppy
HTMX itself doesn’t magically create XSS, but its features encourage patterns that can.
1. Swapping attacker-controlled HTML
This is the main one.
<div hx-get="/profile/preview" hx-trigger="load"></div>
If /profile/preview returns untrusted HTML, HTMX swaps it in. Done.
Keep HTMX endpoints boring. They should return fragments generated from trusted templates, with escaped variables, and minimal logic.
2. Using hx-vals, hx-headers, or custom attributes unsafely
Developers sometimes inject user data into HTMX attributes:
<button hx-post="/api" hx-vals='{"name":"{{ name }}"}'>Save</button>
If name is not encoded correctly for JSON-inside-HTML-attribute context, things break in fun and dangerous ways.
Safer approach: keep dynamic values in actual form fields.
<form hx-post="/api">
<input type="hidden" name="name" value="{{ name }}">
<button>Save</button>
</form>
That’s simpler and much harder to mess up.
3. Returning scriptable markup
Even if <script> tags don’t execute in a given insertion path, plenty of HTML is still dangerous:
<img src="x" onerror="alert(1)">
<svg onload="alert(1)"></svg>
<a href="javascript:alert(1)">click</a>
<div style="background:url(javascript:alert(1))"></div>
If your defense is “browsers usually don’t run script tags inserted this way,” you’re defending the wrong thing. XSS is bigger than <script>.
4. Client-side event handling around swapped content
A lot of HTMX apps add JavaScript hooks after swaps:
document.body.addEventListener("htmx:afterSwap", (event) => {
const el = event.target;
el.querySelectorAll("[data-enhance]").forEach(setupWidget);
});
If setupWidget reads attacker-controlled attributes and pipes them into innerHTML, eval, new Function, or unsafe URL sinks, you’ve created DOM XSS on top of server XSS risk.
Example of what not to do:
function setupWidget(el) {
const template = el.getAttribute("data-template");
el.innerHTML = template;
}
If that attribute came from swapped HTML, game over.
Safe rendering patterns for HTMX
Pattern 1: Return HTML generated from templates only
Good:
# Flask example
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.post("/comments")
def comments():
text = request.form["text"]
return render_template_string('<div class="comment">{{ text }}</div>', text=text)
Auto-escaping does the work.
Bad:
@app.post("/comments")
def comments():
text = request.form["text"]
return f'<div class="comment">{text}</div>'
Pattern 2: Prefer plain text where HTML is unnecessary
Sometimes HTML fragments are overkill. If the response is just text, return text and insert it safely yourself.
<button id="load-status" hx-get="/status" hx-swap="none">Load</button>
<div id="status"></div>
<script nonce="{{ csp_nonce }}">
document.body.addEventListener("htmx:afterRequest", async (event) => {
if (event.target.id !== "load-status") return;
const text = event.detail.xhr.responseText;
document.getElementById("status").textContent = text;
});
</script>
textContent is your friend.
Pattern 3: Sanitize rich HTML on the server
If your product genuinely needs user-provided HTML, sanitize it before storing or before rendering.
Pseudo-example:
app.post("/bio", (req, res) => {
const dirty = req.body.bio_html;
const clean = sanitizeHtml(dirty, {
allowedTags: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
allowedAttributes: {
a: ["href"]
},
allowedSchemes: ["http", "https", "mailto"]
});
res.send(`<div class="bio">${clean}</div>`);
});
Be strict. Every extra tag and attribute is another thing to audit.
Use CSP as backup, not as your only fix
A good Content Security Policy won’t fix unsafe HTML rendering, but it can block a lot of exploit paths. For HTMX apps, I strongly recommend building toward a CSP that disables inline scripts and restricts script sources.
Example:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
If you need help implementing CSP nonces and tightening policy over time, https://csp-guide.com is a solid reference.
The practical win here is that payloads like onerror=... and inline <script> become much harder to execute under a strict policy.
Still, if you render dangerous HTML, CSP is just reducing blast radius.
A quick review checklist for HTMX endpoints
When I review HTMX code, I ask these questions:
- Does this endpoint return HTML?
- Does any part of that HTML contain user-controlled data?
- Is that data escaped for the correct context?
- Are any raw HTML template features being used?
- Are any URLs validated against an allowlist?
- Does swapped content later get processed by JavaScript?
- Are we relying on inline event handlers anywhere?
- Do we have a CSP that blocks inline script execution?
If the answer to #2 is yes and #3 is “I think so,” I assume there’s a bug until proven otherwise.
A safer HTMX comment flow
Here’s a compact version I’d actually ship.
Template
<form hx-post="/comments" hx-target="#comments" hx-swap="beforeend">
<label for="text">Comment</label>
<input id="text" name="text" maxlength="500">
<button type="submit">Post</button>
</form>
<div id="comments">
{% for comment in comments %}
<div class="comment">{{ comment.text }}</div>
{% endfor %}
</div>
Server
from flask import Flask, request, render_template_string
app = Flask(__name__)
comments = []
comment_fragment = """
<div class="comment">{{ text }}</div>
"""
@app.post("/comments")
def add_comment():
text = request.form["text"]
comments.append({"text": text})
return render_template_string(comment_fragment, text=text)
No raw HTML. No custom DOM parsing. No inline JS. Very little to screw up.
That’s the sweet spot for HTMX security: keep the server response predictable, escaped, and boring. Boring HTML is hard to exploit. That’s exactly what you want.