Skip to content
TutorialSecurity & OWASP

Injection and XSS: where Vue protects you and where it does not

Vue escapes text and bound attributes for you. v-html, user-supplied URLs and styles, and the SQL and shell commands in your server routes are yours to secure, with a Content-Security-Policy as the net under all of them.

vuejs.school team22 Jan 20267 min readVue 3.5 · Nuxt 4.2

OWASP defines injection as a flaw that lets untrusted user input reach an interpreter and makes the interpreter execute part of that input as a command. The interpreter can be the browser's HTML parser, in which case the attack is cross-site scripting (XSS), or a database, a shell or a template engine. The OWASP Top 10:2025 ranks the whole family fifth, down from third in 2021, and counts XSS inside it.

By the end of this post you will know which of those holes Vue closes for you, which ones you open yourself, and how to close each one. The examples use Nuxt 4.2, with components under app/ and routes under server/; the browser-side parts apply to any Vue 3.5 app.

What Vue escapes for you

Vue's own security guide is short, and the first half is reassuring. Text interpolation is escaped: if comment.body contains <script>alert("hi")</script>, then {{ comment.body }} renders those characters as visible text, because Vue sets it with the browser's textContent. Attribute bindings are escaped the same way, through setAttribute: a value of " onclick="alert('hi') in :title="value" cannot close the attribute and start a new one. In both cases the browser API does the escaping, so, as the guide puts it, a vulnerability can only exist if the browser itself is vulnerable.

That covers the large majority of what a component renders. The holes are the places where you ask Vue to step aside, and the server code it never sees.

Sanitise HTML before v-html renders it

v-html sets the element's innerHTML, so the string is parsed as HTML instead of shown as text:

app/components/CommentBody.vueBreaks
<script setup lang="ts">
const { html } = defineProps<{ html: string }>()
</script>

<template>
  <div class="comment" v-html="html" />
</template>

An event handler in that string runs for every visitor who sees the comment: <img src="x" onerror="…"> is enough. When v-html runs in the browser, a <script> tag in the string stays inert; on a server-rendered Nuxt page it goes out in the HTML and runs on load.

If comments may contain markup, the fix is to sanitise the HTML against an allow-list. DOMPurify is the library for that:

app/components/CommentBody.vueWorks
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'

const { html } = defineProps<{ html: string }>()

const safe = computed(() => DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'code'],
  ALLOWED_ATTR: ['href'],
}))
</script>

<template>
  <div class="comment" v-html="safe" />
</template>

The allow-list is the important half. Listing what is permitted means a tag you never thought of is dropped by default, where a block-list would let it through. With this configuration DOMPurify also removes event handlers, attributes such as style and class, and any href that holds a javascript: URL.

On the Nuxt server, this component fails with a sanitize is not a function error: DOMPurify needs a DOM, and Node has none. Import it from isomorphic-dompurify instead, which exposes the same sanitize() on the server and in the browser, or render the component inside <ClientOnly>.

A custom directive that writes el.innerHTML = binding.value is v-html under another name and needs the same treatment. Write to el.textContent unless you have sanitised. The guide's first rule goes further: never use user-provided content as a component template, because template expressions run as JavaScript. Keep <component :is> away from input as well. A string that names no registered component renders as an element of that name, and script is a valid name.

Allow-list the URLs and styles users control

Escaping protects the attribute's boundaries, not its meaning. A javascript: URL is a perfectly valid href, and Vue binds it as given:

app/components/ProfileLink.vueBreaks
<script setup lang="ts">
const { href } = defineProps<{ href: string }>()
</script>

<template>
  <a :href="href"><slot /></a>
</template>

Used as <ProfileLink :href="profile.website">, a profile whose website is javascript:fetch('/api/me').then(...) runs that code when another user clicks the link.

The guide's advice is to sanitise URLs on the server before they are saved, so every client of your API is covered, and it names the @braintree/sanitize-url package. The component should also refuse anything that is not a web address, so the two layers back each other up:

app/components/ProfileLink.vueWorks
<script setup lang="ts">
import { computed } from 'vue'

const { href } = defineProps<{ href: string }>()
const ALLOWED = ['http:', 'https:', 'mailto:']

const safeHref = computed(() => {
  try {
    const url = new URL(href, 'https://placeholder.invalid')
    return ALLOWED.includes(url.protocol) ? href : undefined
  } catch {
    return undefined
  }
})
</script>

<template>
  <a v-if="safeHref" :href="safeHref" rel="noopener noreferrer"><slot /></a>
  <span v-else><slot /></span>
</template>

Parsing with new URL() beats a regular expression: it lowercases the scheme and strips the leading whitespace and control characters attackers use to hide javascript:. The placeholder base lets a relative link such as /users/1 parse, so it passes as https:. The same check belongs on :src and on any URL you put into a style binding.

Styles are the quieter version of the same problem. A string bound with :style="profile.css" lets a user position a transparent element over your login button, which the guide calls clickjacking. Use the object syntax and choose the properties: :style="{ color: profile.colour }" lets them pick a colour and nothing else. Vue already refuses to render a <style> tag inside a template.

Keep request data out of SQL and shell commands

A Nuxt server route is a Node function, and the browser-side escaping is nowhere near it. The classic mistake is a query assembled from the request. In these examples db is a better-sqlite3 database exported from server/utils/db.ts:

server/api/comments.get.tsBreaks
import { db } from '../utils/db'

export default defineEventHandler((event) => {
  const { postId } = getQuery(event)
  return db.prepare(`SELECT id, author, body FROM comments WHERE post_id = ${postId}`).all()
})

A request for /api/comments?postId=1 OR 1=1 returns every comment in the table, and a more patient attacker reads other tables through a UNION.

OWASP's preferred fix is to keep data separate from the command with a safe API, which for SQL means a parameterised query. Where that is not possible, it falls back on positive validation on the server, meaning an allow-list of what a value may look like. Do both:

server/api/comments.get.tsWorks
import { z } from 'zod'
import { db } from '../utils/db'

const schema = z.object({ postId: z.coerce.number().int().positive() })

export default defineEventHandler(async (event) => {
  const { postId } = await getValidatedQuery(event, schema.parse)
  return db.prepare('SELECT id, author, body FROM comments WHERE post_id = ?').all(postId)
})

The ? is a placeholder. SQLite compiles the statement from the SQL text alone and binds the value to it afterwards, so the value can never change the shape of the statement, whatever it contains. Every mainstream driver and query builder offers the same placeholder form.

The schema rejects anything that is not a positive integer with a 400 before the query runs. It does not replace the placeholder: OWASP says validation is not a complete defence, because many fields, such as a comment body or a name like O'Brien, must accept the characters SQL treats as special.

The shell has the same rule. exec() from node:child_process hands a single string to a shell, where ; and backticks mean something. execFile() takes the program and its arguments as an array, and by default no shell is involved. This route makes a thumbnail with ImageMagick's convert:

server/api/thumbnail.post.ts
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

const run = promisify(execFile)

export default defineEventHandler(async (event) => {
  const { name } = await readBody<{ name: string }>(event)
  if (!/^[\w-]+\.(png|jpe?g)$/.test(name)) throw createError({ statusCode: 400 })
  await run('convert', [`uploads/${name}`, '-resize', '200x200', `thumbs/${name}`])
  return { ok: true }
})

The regular expression is a second guard: it also stops ../ from walking out of the uploads folder. That flaw, path traversal, is not injection, but it has the same cure: a strict allow-list of what a value may look like.

Add a Content-Security-Policy as a safety net

Everything above assumes you found every hole. A Content-Security-Policy (CSP) is the header for the one you missed. It tells the browser where scripts may come from, and with script-src 'self' an injected inline <script> or onerror handler does not run even if the sanitiser failed. Start in report-only mode, which logs violations to the browser console without blocking anything, through a route rule:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/**': {
      headers: {
        'Content-Security-Policy-Report-Only':
          "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'",
      },
    },
  },
})

Open the app and read the console. You will see violations for Nuxt's own inline scripts, such as the one that sets the runtime config (window.__NUXT__). That is why the enforcing header needs more than a static string: every inline script must carry a nonce (a random value generated per request and repeated in the header), or the header must list a hash of the script's contents.

The nuxt-security module does that work. For server-rendered pages it adds 'nonce-{{nonce}}' to script-src, with 'strict-dynamic' so that scripts loaded by a trusted script are trusted too; for static builds it uses hashes. Add it when you move from report-only to enforcing; until then, the report-only header costs nothing and tells you what an enforcing one would break.

Check an app for injection before a release

  • Open each component that renders text from a user. It should use {{ }} or a bound attribute, and every v-html should sit behind DOMPurify with an allow-list.
  • Search your directives for innerHTML, and your templates for :is fed by anything from a request.
  • Find every :href and :src that a user can influence and confirm the protocol is checked, and every :style that takes a string.
  • In server/, search for template literals inside prepare(, query( or exec(, and for any exec( from child_process at all. Each one should be a placeholder or an argument array.
  • Look for the CSP header in a response and read the console for what it reports.

If each of those checks passes, injection is handled where it happens, and the header is there for the day you are wrong.

Check it stuck3 questions from the Injection and cross-site scripting quiz.
Practice now
Was this helpful?

Keep reading