---
title: "Injection and XSS: where Vue protects you and where it does not · vuejs.school"
description: "Vue escapes what you interpolate and bind, nothing else. Broken and fixed versions of v-html, javascript: URLs and SQL strings, plus a CSP as the safety net."
canonical_url: "https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you"
meta:
  "og:description": "Vue escapes what you interpolate and bind, nothing else. Broken and fixed versions of v-html, javascript: URLs and SQL strings, plus a CSP as the safety net."
  "og:image": "https://vuejs.school/_og/d/a_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90%2Cdescription_~VnVlIGVzY2FwZXMgd2hhdCB5b3UgaW50ZXJwb2xhdGUgYW5kIGJpbmQsIG5vdGhpbmcgZWxzZS4gQnJva2VuIGFuZCBmaXhlZCB2ZXJzaW9ucyBvZiB2LWh0bWwsIGphdmFzY3JpcHQ6IFVSTHMgYW5kIFNRTCBzdHJpbmdzLCBwbHVzIGEgQ1NQIGFzIHRoZSBzYWZldHkgbmV0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbmplY3Rpb24tYW5kLXhzcy13aGVyZS12dWUtcHJvdGVjdHMteW91Ig%2Cs_3SF--23bwlPO4hf6.png"
  "og:image:alt": "Injection and XSS: where Vue protects you and where it does not · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Injection and XSS: where Vue protects you and where it does not · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90%2Cdescription_~VnVlIGVzY2FwZXMgd2hhdCB5b3UgaW50ZXJwb2xhdGUgYW5kIGJpbmQsIG5vdGhpbmcgZWxzZS4gQnJva2VuIGFuZCBmaXhlZCB2ZXJzaW9ucyBvZiB2LWh0bWwsIGphdmFzY3JpcHQ6IFVSTHMgYW5kIFNRTCBzdHJpbmdzLCBwbHVzIGEgQ1NQIGFzIHRoZSBzYWZldHkgbmV0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbmplY3Rpb24tYW5kLXhzcy13aGVyZS12dWUtcHJvdGVjdHMteW91Ig%2Cs_3SF--23bwlPO4hf6.png"
  "twitter:image:alt": "Injection and XSS: where Vue protects you and where it does not · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What Vue escapes for you

Vue's own [security guide](https://vuejs.org/guide/best-practices/security.html) 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.vue**Breaks***

```
<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.vue**Works***

```
<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.vue**Breaks***

```
<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.vue**Works***

```
<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.ts**Breaks***

```
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.ts**Works***

```
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 stuck**3 questions from the Injection and cross-site scripting quiz.

[**Practice now**](https://vuejs.school/learn/security/injection-and-cross-site-scripting?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**API security for AI features: rate limits, prompt injection and error handling**](https://vuejs.school/community/blog/api-security-for-ai-features) [Tutorial · 8 min read**Supply chain security: lockfiles, provenance and OWASP A03**](https://vuejs.school/community/blog/supply-chain-security-lockfiles-provenance-and-owasp-a03)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-22","description":"Vue escapes what you interpolate and bind, nothing else. Broken and fixed versions of v-html, javascript: URLs and SQL strings, plus a CSP as the safety net.","name":"Injection and XSS: where Vue protects you and where it does not","url":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you","breadcrumb":{"@id":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-22","description":"Vue escapes what you interpolate and bind, nothing else. Broken and fixed versions of v-html, javascript: URLs and SQL strings, plus a CSP as the safety net.","headline":"Injection and XSS: where Vue protects you and where it does not","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90%2Cdescription_~VnVlIGVzY2FwZXMgd2hhdCB5b3UgaW50ZXJwb2xhdGUgYW5kIGJpbmQsIG5vdGhpbmcgZWxzZS4gQnJva2VuIGFuZCBmaXhlZCB2ZXJzaW9ucyBvZiB2LWh0bWwsIGphdmFzY3JpcHQ6IFVSTHMgYW5kIFNRTCBzdHJpbmdzLCBwbHVzIGEgQ1NQIGFzIHRoZSBzYWZldHkgbmV0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbmplY3Rpb24tYW5kLXhzcy13aGVyZS12dWUtcHJvdGVjdHMteW91Ig%2Cs_3SF--23bwlPO4hf6.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/injection-and-xss-where-vue-protects-you#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Injection and XSS: where Vue protects you and where it does not","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90%2Cdescription_~VnVlIGVzY2FwZXMgd2hhdCB5b3UgaW50ZXJwb2xhdGUgYW5kIGJpbmQsIG5vdGhpbmcgZWxzZS4gQnJva2VuIGFuZCBmaXhlZCB2ZXJzaW9ucyBvZiB2LWh0bWwsIGphdmFzY3JpcHQ6IFVSTHMgYW5kIFNRTCBzdHJpbmdzLCBwbHVzIGEgQ1NQIGFzIHRoZSBzYWZldHkgbmV0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbmplY3Rpb24tYW5kLXhzcy13aGVyZS12dWUtcHJvdGVjdHMteW91Ig%2Cs_3SF--23bwlPO4hf6.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~SW5qZWN0aW9uIGFuZCBYU1M6IHdoZXJlIFZ1ZSBwcm90ZWN0cyB5b3UgYW5kIHdoZXJlIGl0IGRvZXMgbm90%2Cdescription_~VnVlIGVzY2FwZXMgd2hhdCB5b3UgaW50ZXJwb2xhdGUgYW5kIGJpbmQsIG5vdGhpbmcgZWxzZS4gQnJva2VuIGFuZCBmaXhlZCB2ZXJzaW9ucyBvZiB2LWh0bWwsIGphdmFzY3JpcHQ6IFVSTHMgYW5kIFNRTCBzdHJpbmdzLCBwbHVzIGEgQ1NQIGFzIHRoZSBzYWZldHkgbmV0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbmplY3Rpb24tYW5kLXhzcy13aGVyZS12dWUtcHJvdGVjdHMteW91Ig%2Cs_3SF--23bwlPO4hf6.png"}]}
```