htmx: Build Interactive Web Apps with HTML · Production Concerns
Securing an htmx App
Defend the contacts app against XSS, injected htmx attributes and CSRF, and lock down htmx's own settings.
What you'll build
A comment feature that safely displays user input, blocks injected hx- attributes, sends a CSRF token with every
request, and uses htmx's security settings.
The idea
htmx moves rendering back to the server, so the classic server-side rules apply, plus one htmx-specific risk:
- Escape output. Template engines (Jinja2, Django, Blade, ERB, Go
html/template) escape by default; never mark user content as safe. An injected<button hx-delete="/account" hx-trigger="load">would otherwise run itself. hx-disablearound user-generated HTML (for example rendered Markdown) stops htmx from processing anyhx-attribute inside, even if sanitising misses one.- CSRF: htmx requests carry cookies, so state-changing requests need a token. Put it once on
<body>withhx-headers(inherited by every request), or add it in anhtmx:configRequestlistener. - Settings: keep
htmx.config.selfRequestsOnly = true(the htmx 2 default), setallowScriptTags: falseif responses never need scripts, andhistoryCacheSize: 0for pages with sensitive data.
The code
<div hx-headers='{"X-CSRF-Token": "a1b2c3"}'>
<form hx-post="/comments" hx-target="#comments" hx-swap="beforeend">
<input name="text" size="60"
value='<button hx-get="/steal" hx-trigger="load">hi</button><img src=x onerror=alert(1)>'>
<button>Post</button>
</form>
<ul id="comments" hx-disable></ul>
</div>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
server.post("/comments", (req) => {
if (req.headers["x-csrf-token"] !== "a1b2c3") return { status: 403, body: "" };
return `<li>${server.escape(req.body.text)} <small>(token ok)</small></li>`;
});
server.get("/steal", () => "<p>This should never be requested.</p>");
</script>
The comment shows as text, no image loads, and the log never shows GET /steal. In a real app the token comes from
your framework:
<!-- Django --> <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<!-- Laravel --> <body hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
Common mistakes
{{ comment|safe }}/{!! $comment !!}on user content: XSS, and injected htmx attributes become live.- Exempting htmx endpoints from CSRF because "they're AJAX": cookies are sent, so they're vulnerable.
- Turning off
selfRequestsOnlyto call another domain from the page: proxy through your server instead. - Caching private pages in htmx's history cache (
sessionStorage) on shared computers.
Key points
- Escape everything; wrap user HTML in
hx-disable. - Send CSRF tokens with
hx-headersand useSameSitecookies. - Keep htmx's defaults strict and tighten them where you can.
Further reading: htmx docs: security · hx-disable · OWASP: CSRF prevention
Practice questions
Why is XSS potentially worse in an htmx app than in a plain HTML page?
Answer
Injected markup can include hx- attributes, which htmx activates: an injected element could send requests (for example hx-delete with hx-trigger="load") as the user.
How do you send a CSRF token with every htmx request?
Answer
Put hx-headers with the token on <body> (it's inherited), or add the header in an htmx:configRequest listener.
What does hx-disable do?
Answer
It stops htmx from processing hx- attributes on the element and its descendants; use it around user-generated content.
Open in the app to mark lessons complete, take notes and track your progress.