Security and Accessibility
|
This section documents the current Vue 3.x release line as published at
the official Vue.js documentation, which is the reference these pages are written and
verified against. No specific patch version is pinned. The Composition API with This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
Vue removes the most common cross-site-scripting hole by escaping interpolated content automatically, and its component model makes accessible markup the path of least resistance. Both still need deliberate attention at the edges. This page follows the Security and Accessibility guides.
Security
Built-in HTML escaping
Text bindings — \{\{ mustache }}, v-text, and : attribute bindings — are escaped before they reach the
DOM, so user data rendered this way cannot introduce markup or script. The
What Vue Does section spells this out.
<script setup lang="ts">
const comment = `<img src=x onerror="alert(document.cookie)">`
</script>
<template>
<!-- rendered as literal text, no image element, no script -->
<p>{{ comment }}</p>
<p :title="comment">hover me</p>
</template>
v-html — the injection you opt into
v-html sets innerHTML verbatim and bypasses all
escaping. Never point it at user-supplied or third-party content. When you must render rich text, sanitise
first with a vetted library and treat its allowlist as security-critical:
<script setup lang="ts">
import DOMPurify from 'dompurify'
const props = defineProps<{ markdownHtml: string }>()
const safe = computed(() => DOMPurify.sanitize(props.markdownHtml))
</script>
<template>
<article v-html="safe" />
</template>
URL, attribute, style, and :is injection
-
URLs — a bound
:hrefor:srcofjavascript:alert(1)executes on click. Validate the scheme (https:/mailto:/ relative) before binding, or run URLs through a sanitiser. The Potential Dangers section covers this. -
Attributes — never bind user data into
:srcdoc,:formaction, or event-handler-like attributes. Binding user text into an ordinary attribute such as:titleis fine (it is escaped). -
Styles — do not interpolate user input into
:stylestrings or<style>blocks; CSS can load resources and, in old engines, run expressions. Bind individual, validated properties instead. -
Dynamic components —
<component :is="…">with a user-controlled value can mount an arbitrary registered component or, with a string, an arbitrary element. Resolve the value against an explicit allowlist map, never pass it straight through.
<script setup lang="ts">
const views = { chart: ChartView, table: TableView } as const
const props = defineProps<{ view: string }>()
const current = computed(() => views[props.view as keyof typeof views] ?? TableView)
function safeHref(raw: string) {
return /^(https?:|mailto:|\/)/.test(raw) ? raw : '#'
}
</script>
<template>
<component :is="current" />
<a :href="safeHref(userLink)">link</a>
</template>
Template injection
Vue templates are code. Never build a template string from user input and hand it to compile(),
h() with a user-derived type, or a runtime template option. Keep templates static and drive them with
data.
Providing server data safely
Injecting state into the page for the client to pick up is a classic XSS sink. Serialise with a
context-aware tool, not JSON.stringify alone (which leaves </script> and <!-- intact):
import serialize from 'serialize-javascript'
const html = `<script>window.__INITIAL__ = ${serialize(state, { isJSON: true })}<` + `/script>`
On the client, read it from a data-* attribute or a typed global — do not eval it.
Content Security Policy
Vue’s production runtime needs no unsafe-eval: SFCs are compiled to render functions at build time. Ship a
strict policy (script-src 'self'; nonce or hash for any inline bootstrap) and keep the runtime-only
Vue build so the new Function template compiler is never bundled. Avoid runtime string templates, which
would require unsafe-eval.
SSR-specific concerns
-
Escape interpolations into the HTML shell (the parts outside the app’s mount point) yourself — Vue only escapes what it renders.
-
Never render request-derived HTML with
v-htmlon the server; a reflected payload becomes stored-looking XSS on first paint. -
Keep per-request state on a fresh app instance; a module-level singleton leaks one user’s data into another’s response.
-
Validate that hydration data matches what the client will re-render, so an attacker cannot smuggle markup through a mismatch.
For the browser mechanism that governs which origins a Vue app may call, and how to validate origins on the API, see What is CORS?.
Accessibility
Vue renders plain HTML, so accessibility is mostly a matter of authoring good templates. The Accessibility guide is the reference.
Semantic templates
Use landmark and heading elements instead of styled `<div>`s; component boundaries do not have to match element boundaries.
<template>
<header>
<nav aria-label="Primary"><!-- ... --></nav>
</header>
<main>
<h1>{{ pageTitle }}</h1>
<article>
<h2>{{ section.title }}</h2>
</article>
</main>
<footer><!-- ... --></footer>
</template>
Skip link
Give keyboard and screen-reader users a way past repeated navigation. See Skip link.
<template>
<a class="skip-link" href="#main">Skip to main content</a>
<!-- ...nav... -->
<main id="main" tabindex="-1"><RouterView /></main>
</template>
<style>
.skip-link {
position: absolute;
transform: translateY(-120%);
}
.skip-link:focus {
transform: translateY(0);
}
</style>
Route-change focus management
An SPA navigation does not move focus or announce the new page. After each route change, move focus to the
main region or the new <h1>:
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { watch, useTemplateRef } from 'vue'
const main = useTemplateRef<HTMLElement>('main')
const route = useRoute()
watch(() => route.fullPath, () => {
main.value?.focus()
})
</script>
<template>
<main ref="main" tabindex="-1">
<RouterView />
</main>
</template>
Pair it with a visually-hidden aria-live="polite" region that you update with the new page title so the
change is announced.
aria-* bindings
Bind ARIA state from reactive data; keep it in sync with what the user sees. See Semantic Forms for the form patterns.
<script setup lang="ts">
const open = ref(false)
const busy = ref(false)
</script>
<template>
<button
:aria-expanded="open"
aria-controls="panel"
@click="open = !open"
>
Details
</button>
<section id="panel" v-show="open" :aria-busy="busy">...</section>
</template>
Labelled form controls
Every control needs a programmatic name — a <label for> tied to a unique id, or aria-label /
aria-labelledby. useId generates SSR-stable
ids:
<script setup lang="ts">
import { useId } from 'vue'
const nameId = useId()
const name = defineModel<string>()
</script>
<template>
<label :for="nameId">Full name</label>
<input :id="nameId" v-model="name" type="text" autocomplete="name" />
</template>
Group related controls in a <fieldset> with a <legend>; associate error text with aria-describedby.
Testing
Automated tools catch a large share of issues — run them, then still keyboard-test and screen-reader-test by hand:
-
vue-axe— a dev-only plugin that runs axe-core against the live component tree and logs violations to the console. -
Lighthouse — the Accessibility category in Chrome DevTools or CI (
@lhci/cli) for a per-page score and specific failures. -
@axe-core/playwrightorjest-axe/vitest-axe— assert zero violations inside component and end-to-end tests.
// main.ts (development only)
if (import.meta.env.DEV) {
const { default: VueAxe } = await import('vue-axe')
app.use(VueAxe)
}
For the standards behind these checks — WCAG, the POUR principles, conformance levels, and the legal requirements — see Web Accessibility.
See also
-
What is CORS? — the same-origin policy and validating origins on the API.
-
Web Accessibility — WCAG, POUR, conformance levels, and validation tools.
-
Performance and Deployment — adding these checks to the CI pipeline and a strict CSP at deploy.