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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

  1. 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.
  2. hx-disable around user-generated HTML (for example rendered Markdown) stops htmx from processing any hx- attribute inside, even if sanitising misses one.
  3. CSRF: htmx requests carry cookies, so state-changing requests need a token. Put it once on <body> with hx-headers (inherited by every request), or add it in an htmx:configRequest listener.
  4. Settings: keep htmx.config.selfRequestsOnly = true (the htmx 2 default), set allowScriptTags: false if responses never need scripts, and historyCacheSize: 0 for pages with sensitive data.

The code

Escaping, hx-disable and a CSRF headerTry it yourself
<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 selfRequestsOnly to 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-headers and use SameSite cookies.
  • 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.

Was this page helpful?