Alpine.js feels safe because it stays close to plain HTML. That’s part of why people trust it too much.
I’ve seen teams assume “small framework” means “small attack surface.” Not true. Alpine gives you powerful ways to bind data into the DOM, evaluate expressions, and react to user input. Those same features can become XSS sinks if you feed them untrusted data.
If you build with Alpine, the good news is simple: most XSS issues come from a handful of dangerous patterns. Avoid those, and Alpine is pretty manageable.
Where XSS shows up in Alpine.js
The main risk areas are:
- Rendering untrusted HTML
- Building Alpine expressions from user input
- Writing unsafe attribute values
- Mixing server-rendered user content with Alpine directives
- Using
x-ignoreor direct DOM manipulation in a way that bypasses safe defaults
Alpine itself doesn’t magically sanitize data for you. If you tell it to inject HTML, it will inject HTML.
Safe by default: x-text
If you need to display untrusted content, x-text is usually the right choice.
<div x-data="{ message: '<img src=x onerror=alert(1)>' }">
<span x-text="message"></span>
</div>
This renders the string as text, not HTML. The browser will show:
<img src=x onerror=alert(1)>
No script execution. That’s what you want for usernames, comments, search queries, and anything else users control.
Same rule applies to moustache-style server rendering around Alpine. If the content is untrusted, escape it before it reaches the page.
Dangerous: x-html
x-html is the Alpine equivalent of saying, “I know what I’m doing,” right before things go sideways.
<div x-data="{ content: '<img src=x onerror=alert(1)>' }">
<div x-html="content"></div>
</div>
That payload becomes real DOM. The onerror handler executes. That’s classic XSS.
If content came from:
- a CMS field
- a markdown renderer
- a query parameter
- localStorage
- an API response
- a WYSIWYG editor
then you have a problem unless it was sanitized properly.
Safer pattern: sanitize before x-html
If you truly need rich HTML, sanitize it before binding.
<div x-data="postComponent()">
<article x-html="safeContent"></article>
</div>
<script>
function postComponent() {
return {
rawContent: '',
safeContent: '',
async init() {
const res = await fetch('/api/post/123');
const data = await res.json();
this.rawContent = data.content;
this.safeContent = sanitizeHtml(this.rawContent);
}
};
}
function sanitizeHtml(input) {
const template = document.createElement('template');
template.innerHTML = input;
for (const el of template.content.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src') &&
(value.startsWith('javascript:') || value.startsWith('data:text/html'))) {
el.removeAttribute(attr.name);
}
}
}
for (const bad of template.content.querySelectorAll('script, iframe, object, embed')) {
bad.remove();
}
return template.innerHTML;
}
</script>
This is better than nothing, but I wouldn’t trust a homegrown sanitizer for complex user HTML. For production, use a mature sanitizer and keep its config tight. Alpine doesn’t provide one for you.
x-bind can become an injection problem
A lot of developers focus only on HTML injection and miss attribute injection. Alpine’s x-bind and shorthand : are fine when used carefully, but risky when you bind attacker-controlled values into sensitive attributes.
Example: unsafe href
<a x-data="{ url: 'javascript:alert(1)' }" :href="url">
Click me
</a>
That won’t execute immediately, but it creates an exploitable link. If a user clicks it, game over.
Fix: validate URL schemes
<div x-data="linkComponent()">
<a :href="safeUrl" rel="noopener noreferrer">Profile</a>
</div>
<script>
function linkComponent() {
return {
url: 'javascript:alert(1)',
get safeUrl() {
return this.isSafeUrl(this.url) ? this.url : '/';
},
isSafeUrl(value) {
try {
const u = new URL(value, window.location.origin);
return ['http:', 'https:', 'mailto:'].includes(u.protocol);
} catch {
return false;
}
}
};
}
</script>
Do the same for src, action, formaction, and iframe-related attributes.
Alpine expressions are not a template language for user input
This is where people get clever and create self-inflicted XSS.
Alpine directives evaluate JavaScript expressions. That means if you build directive values dynamically from untrusted input, you’re effectively generating code.
Bad idea:
<div x-data="{ expr: 'alert(1)' }">
<button x-on:click="eval(expr)">Run</button>
</div>
Worse idea:
<div x-data="{ handler: 'fetch(`/api?x=`+document.cookie)' }">
<button x-on:click="new Function(handler)()">Run</button>
</div>
If user input reaches eval, Function, string-based timers, or any “execute this string as code” path, Alpine is just the delivery mechanism.
Rule: user input is data, never code
Use a fixed set of actions instead of dynamic expressions.
<div x-data="actionsComponent()">
<button @click="runAction(userAction)">Run</button>
</div>
<script>
function actionsComponent() {
return {
userAction: 'save',
runAction(action) {
const actions = {
save: () => console.log('saving'),
preview: () => console.log('previewing'),
cancel: () => console.log('cancelling')
};
if (actions[action]) {
actions[action]();
}
}
};
}
</script>
That pattern scales well and doesn’t turn your UI into a code execution engine.
Server-side injection into Alpine attributes
This one is common in apps that render HTML on the server and sprinkle Alpine on top.
Imagine a template like this:
<div x-data="{ name: '{{ user.name }}' }">
<span x-text="name"></span>
</div>
If user.name contains a quote, it can break out of the JavaScript string inside the attribute.
Example payload:
'}; alert(1); //
Rendered output:
<div x-data="{ name: ''}; alert(1); //' }">
Now you’ve got script execution during Alpine expression parsing.
Fix: serialize data as JSON, not handwritten JS strings
Safer pattern:
<div x-data='userComponent({"name":"Alice"})'>
<span x-text="name"></span>
</div>
<script>
function userComponent(user) {
return {
name: user.name
};
}
</script>
Or with a script block:
<script type="application/json" id="user-data">
{"name":"Alice"}
</script>
<div x-data="userComponent()">
<span x-text="name"></span>
</div>
<script>
function userComponent() {
const raw = document.getElementById('user-data').textContent;
const user = JSON.parse(raw);
return {
name: user.name
};
}
</script>
That’s much safer than embedding raw user content inside Alpine expressions.
x-init and startup-time mistakes
x-init is handy, but it’s another place where people accidentally treat strings as executable code.
Bad:
<div x-data="{ payload: location.hash.slice(1) }"
x-init="setTimeout(payload, 0)">
</div>
If the hash contains JavaScript and the browser accepts string execution in that path, you’ve created DOM XSS.
Safer:
<div x-data="initComponent()" x-init="init()"></div>
<script>
function initComponent() {
return {
init() {
const hash = location.hash.slice(1);
this.handleHash(hash);
},
handleHash(value) {
console.log('Hash value:', value);
}
};
}
</script>
Keep code paths static. Pass data as arguments.
Don’t trust data from “internal” sources
A lot of Alpine XSS bugs come from data that developers don’t think of as user input:
location.searchlocation.hashdocument.referrerpostMessagelocalStorage- values from hidden form fields
- API responses that include user-generated content
If it can be influenced by a user, another site, or a compromised backend path, treat it as untrusted.
Example:
<div x-data="{ q: new URLSearchParams(location.search).get('q') }">
<div x-html="q"></div>
</div>
That’s exploitable with a crafted URL. Switching to x-text fixes it immediately.
<div x-data="{ q: new URLSearchParams(location.search).get('q') || '' }">
<div x-text="q"></div>
</div>
CSP helps, but don’t expect miracles
A strong Content Security Policy can limit the blast radius of XSS. It’s especially useful when someone slips an unsafe x-html or inline event payload into the page.
That said, CSP is not a sanitizer. If you render attacker HTML into the DOM, you still have a bug.
For Alpine apps, I like CSP as a backup layer:
- restrict script sources
- avoid unsafe inline script where possible
- use nonces or hashes when needed
- lock down
object-srcand similar legacy vectors
For implementation details, https://csp-guide.com is a good reference, and Alpine’s official docs are worth checking for framework-specific CSP behavior.
Practical rules I follow in Alpine projects
These are the habits that prevent most messes:
- Use
x-textfor untrusted content. - Treat
x-htmlas dangerous by default. - Sanitize rich HTML before rendering it.
- Validate URLs before binding to
href,src, oraction. - Never build Alpine expressions from user input.
- Never pass untrusted strings to
eval,Function, or string-based timers. - Serialize server data as JSON, not inline JavaScript fragments.
- Treat query params, hashes, storage, and API data as untrusted.
- Add CSP as a containment layer, not a primary fix.
- Review any place Alpine writes to the DOM or evaluates expressions.
A simple secure Alpine example
Here’s a small component that handles both plain text and optional rich content safely.
<div x-data="commentComponent()">
<h2 x-text="author"></h2>
<p x-text="body"></p>
<template x-if="allowRichText">
<div x-html="safeRichBody"></div>
</template>
</div>
<script>
function commentComponent() {
return {
author: '',
body: '',
allowRichText: false,
safeRichBody: '',
async init() {
const res = await fetch('/api/comment/1');
const data = await res.json();
this.author = String(data.author || '');
this.body = String(data.body || '');
if (data.allowRichText === true) {
this.allowRichText = true;
this.safeRichBody = sanitizeHtml(String(data.richBody || ''));
}
}
};
}
function sanitizeHtml(input) {
const template = document.createElement('template');
template.innerHTML = input;
for (const el of template.content.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith('on')) el.removeAttribute(attr.name);
if (['href', 'src'].includes(name) && value.startsWith('javascript:')) {
el.removeAttribute(attr.name);
}
}
}
for (const el of template.content.querySelectorAll('script, iframe, object, embed')) {
el.remove();
}
return template.innerHTML;
}
</script>
That’s the mindset I’d use across an Alpine codebase: default to text, sanitize when HTML is unavoidable, and never blur the line between data and code.
Alpine is lightweight, not foolproof. If you keep that in your head while building, you’ll avoid most XSS bugs before they exist.