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:
- Read attacker-controlled input
- Pass it through a controller
- 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.innerHTMLelement.outerHTMLelement.insertAdjacentHTML()document.write()Range#createContextualFragment()with unsanitized input- setting JavaScript event handler attributes like
onclick - assigning attacker input into URL-bearing attributes without validation:
hrefsrcactionformActionsrcdoc
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
srcwhen 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:
queryValuegoes into HTMLreturnToValuegoes 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:
- Assume all Stimulus values are untrusted
- Use
textContentby default - Never interpolate untrusted data into HTML strings
- Create elements with DOM APIs instead of
innerHTML - Validate URLs before assigning them
- Keep
data-actionand other Stimulus attributes static - Sanitize rich HTML before injection
- 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.