Stimulus feels safe at first glance. It does not ship a template engine, it nudges you toward small controllers, and most of the code you write is “just DOM code.” That last part is exactly where XSS creeps in.

Stimulus does not create XSS by itself. Your controller code does.

If you take untrusted data from data-* attributes, query params, server-rendered HTML fragments, or API responses and push it into dangerous DOM sinks, you have DOM XSS. Stimulus makes those flows easy to write, which means you need a clear rule set.

Where XSS shows up in Stimulus

The usual pattern looks like this:

  1. Read attacker-controlled input
  2. Pass it through a controller
  3. Write it into the DOM unsafely

A simple bad example:

<div
  data-controller="profile"
  data-profile-name-value="<img src=x onerror=alert(1)>"
>
  <div data-profile-target="output"></div>
</div>
// controllers/profile_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]
  static values = { name: String }

  connect() {
    this.outputTarget.innerHTML = `<p>${this.nameValue}</p>`
  }
}

That fires immediately because innerHTML parses attacker input as markup.

This is not a Stimulus-specific bug. But Stimulus values and targets make it very easy to move tainted data around cleanly, so the vulnerable code can look deceptively tidy.

The dangerous sinks in Stimulus controllers

If a value is untrusted, treat these APIs as high risk:

  • element.innerHTML
  • element.outerHTML
  • element.insertAdjacentHTML()
  • document.write()
  • Range#createContextualFragment() with unsanitized input
  • setting JavaScript event handler attributes like onclick
  • assigning attacker input into URL-bearing attributes without validation:
    • href
    • src
    • action
    • formAction
    • srcdoc

A lot of real bugs are just this:

this.resultsTarget.innerHTML = response.html

Or this:

this.linkTarget.href = this.urlValue

That second one matters because javascript: URLs are still code execution in many contexts.

The safest default: use text, not HTML

If you want to display user input, use textContent.

<div data-controller="comment" data-comment-body-value="Hello <b>world</b>">
  <div data-comment-target="body"></div>
</div>
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["body"]
  static values = { body: String }

  connect() {
    this.bodyTarget.textContent = this.bodyValue
  }
}

The browser will render the literal text Hello <b>world</b> instead of parsing it.

Same rule for labels, status messages, search terms, usernames, and anything else that does not need markup.

Avoid building HTML strings in controllers

I see this a lot in Stimulus codebases:

this.listTarget.innerHTML += `
  <li class="result">
    <a href="${item.url}">${item.title}</a>
  </li>
`

This combines two problems:

  • HTML parsing via innerHTML
  • attribute injection via string interpolation

Build DOM nodes instead.

renderItem(item) {
  const li = document.createElement("li")
  li.className = "result"

  const a = document.createElement("a")
  a.textContent = item.title
  a.href = this.safeUrl(item.url)

  li.appendChild(a)
  this.listTarget.appendChild(li)
}

safeUrl(value) {
  try {
    const url = new URL(value, window.location.origin)
    if (!["http:", "https:"].includes(url.protocol)) {
      return "#"
    }
    return url.toString()
  } catch {
    return "#"
  }
}

This is boring code. Boring code is good security code.

Stimulus values are not trusted

A common mistake is assuming static values gives you validated data. It does not. It gives you typed conversion, not sanitization.

<div
  data-controller="promo"
  data-promo-message-value="<svg onload=alert(1)>"
></div>
static values = { message: String }

That String only means “convert this to a string.” It does not make it safe.

If a Stimulus value comes from server-rendered HTML, remember the server may be reflecting user input. Treat it as untrusted unless you explicitly generated it yourself from safe constants.

Rendering server HTML fragments safely

Hotwire apps often fetch HTML partials and inject them into the page. That is convenient, but if the fragment contains attacker-controlled markup, you have XSS.

Bad:

async loadPreview() {
  const response = await fetch(`/preview?q=${encodeURIComponent(this.queryValue)}`)
  const html = await response.text()
  this.previewTarget.innerHTML = html
}

Safer options:

Option 1: return JSON and render text/DOM nodes

async loadPreview() {
  const response = await fetch(`/preview.json?q=${encodeURIComponent(this.queryValue)}`)
  const data = await response.json()

  this.previewTarget.replaceChildren()

  const title = document.createElement("h3")
  title.textContent = data.title

  const summary = document.createElement("p")
  summary.textContent = data.summary

  this.previewTarget.append(title, summary)
}

Option 2: sanitize before injection

If you truly need rich HTML, sanitize it before it hits innerHTML. For modern browsers, Trusted Types is a strong guardrail when paired with sanitization. Stimulus works fine in apps that enforce Trusted Types, and it is worth doing if your frontend has lots of dynamic HTML flows.

A policy-based setup usually lives outside the controller, then the controller only accepts trusted HTML objects. Pair that with CSP. The CSP details are worth reviewing at https://csp-guide.com.

Be careful with data-action and dynamic attributes

Stimulus uses data-action to bind events. Normally that is static in your templates:

<button data-action="click->cart#add">Add</button>

That is fine.

The problem starts when code dynamically writes attributes from untrusted input:

this.element.setAttribute("data-action", this.actionValue)

An attacker may not get script execution directly from data-action, but once you let users control framework behavior through attributes, weird things happen fast. The same goes for data-controller, data-target, and data-*-value. Keep those static or generated from trusted constants.

URL-based XSS in Stimulus controllers

People focus on innerHTML and miss URL sinks.

Bad:

connect() {
  this.linkTarget.href = this.redirectValue
}

If redirectValue is javascript:alert(1), that link becomes an execution gadget.

Safer:

connect() {
  this.linkTarget.href = this.safeUrl(this.redirectValue)
}

safeUrl(value) {
  try {
    const url = new URL(value, window.location.origin)
    return ["http:", "https:"].includes(url.protocol) ? url.toString() : "#"
  } catch {
    return "#"
  }
}

Apply the same validation to:

  • iframe src
  • form action
  • image src when privacy or mixed-content matters
  • any redirect destination

A realistic vulnerable controller and a fixed version

Here is the kind of controller I would flag in review immediately.

Vulnerable

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["results"]
  static values = {
    query: String,
    returnTo: String
  }

  connect() {
    this.resultsTarget.innerHTML = `
      <p>Results for: ${this.queryValue}</p>
      <a href="${this.returnToValue}">Back</a>
    `
  }
}

Two bugs:

  • queryValue goes into HTML
  • returnToValue goes into a URL attribute

Fixed

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["results"]
  static values = {
    query: String,
    returnTo: String
  }

  connect() {
    const wrapper = document.createElement("div")

    const p = document.createElement("p")
    p.textContent = `Results for: ${this.queryValue}`

    const a = document.createElement("a")
    a.textContent = "Back"
    a.href = this.safeUrl(this.returnToValue)

    wrapper.append(p, a)
    this.resultsTarget.replaceChildren(wrapper)
  }

  safeUrl(value) {
    try {
      const url = new URL(value, window.location.origin)
      if (!["http:", "https:"].includes(url.protocol)) return "#"
      return url.toString()
    } catch {
      return "#"
    }
  }
}

Not flashy, but solid.

CSP helps, but it does not fix sloppy DOM code

A strict Content Security Policy is a great backstop for Stimulus apps, especially if you block inline scripts and adopt Trusted Types where possible. CSP can turn many XSS bugs into harmless broken payloads instead of code execution.

Still, CSP is not a substitute for safe DOM updates. If your controller keeps pouring attacker input into innerHTML, you are one policy exception away from trouble.

For implementation details and policy examples, see https://csp-guide.com.

Practical rules I use in Stimulus code reviews

These rules catch most bugs quickly:

  1. Assume all Stimulus values are untrusted
  2. Use textContent by default
  3. Never interpolate untrusted data into HTML strings
  4. Create elements with DOM APIs instead of innerHTML
  5. Validate URLs before assigning them
  6. Keep data-action and other Stimulus attributes static
  7. Sanitize rich HTML before injection
  8. Use CSP as a backstop, not your only defense

A quick secure pattern for Stimulus

If you want one pattern to copy into most controllers, use this mindset:

updateUserCard(user) {
  this.cardTarget.replaceChildren()

  const name = document.createElement("h2")
  name.textContent = user.name

  const bio = document.createElement("p")
  bio.textContent = user.bio

  const profile = document.createElement("a")
  profile.textContent = "View profile"
  profile.href = this.safeUrl(user.profileUrl)

  this.cardTarget.append(name, bio, profile)
}

No HTML parsing. No string concatenation. URL validation at the edge.

That is how you keep Stimulus pleasant without turning every controller into an XSS gadget.

If your team uses Stimulus heavily, I would enforce one simple standard: innerHTML in controllers should be rare enough that every use gets a security review. That rule alone eliminates a lot of avoidable bugs.