Three ordinary features can each give an attacker a way in when you build them the obvious way: a comment that renders the HTML a visitor wrote, a profile link that binds whatever URL a visitor typed, and a widget that calls a third-party API with your key in its code. Each one has a short fix (a sanitiser, a check on the link's scheme, a server route), shown below next to the broken version.
The Nuxt examples use the Nuxt 4 layout, with components under app/ and API routes under server/.
What Vue escapes for you
Cross-site scripting (XSS) is when text one visitor typed ends up running as code in another visitor's browser. The classic way in is a comment that contains <script> and a page that inserts it as HTML.
Vue's template syntax is safe here by design. Text interpolation, {{ comment }}, sets the text content of the node, so <script>alert(1)</script> is shown as those literal characters. Attribute bindings, :title="comment", are set through DOM APIs (a property such as title, or setAttribute), never parsed as HTML, so a value like " onclick="alert(1) cannot close the attribute and add a handler. When Nuxt renders a page on the server, Vue escapes the same values as it writes the HTML: < becomes < and " becomes ".
The Vue security guide describes this escaping, and adds the rule above all others: never build a template from a string a visitor controls, because a template is compiled into JavaScript.
Sanitise HTML before it reaches v-html
v-html sets innerHTML, and during server rendering Vue writes the string into the page's HTML unescaped. Either way the string becomes live DOM: event handler attributes run, and so does a <script> tag in the server-rendered HTML (one inserted through innerHTML in the browser does not). That is the point of v-html, and the danger.
<script setup lang="ts">
defineProps<{ html: string }>()
</script>
<template>
<div class="comment" v-html="html" />
</template>
If html came from a visitor, from a CMS editor you do not fully trust, or from a Markdown converter without its own sanitiser, this component is an XSS hole. An <img src=x onerror="..."> in a comment runs in every reader's browser.
When you really need rich HTML, sanitise it first. DOMPurify is the standard library for that: it parses the string with the browser's own parser, removes everything that is not on an allow list, and returns clean HTML. Install it with npm install dompurify; version 3.2 ships its own TypeScript types.
<script setup lang="ts">
import DOMPurify from 'dompurify'
import { computed } from 'vue'
const props = defineProps<{ html: string }>()
const clean = computed(() =>
DOMPurify.sanitize(props.html, {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href'],
}),
)
</script>
<template>
<ClientOnly>
<div class="comment" v-html="clean" />
</ClientOnly>
</template>
The allow list is deliberately short: tags a comment needs, and href as the only attribute. DOMPurify also drops javascript: URLs from the attributes it keeps. Sanitise as late as possible, right before v-html, so nothing between storage and screen can undo it.
The <ClientOnly> wrapper is there because Nuxt renders components on the server first, and DOMPurify needs a DOM, which the server does not have. In Node, DOMPurify 3.2 has no sanitize function at all, so without the wrapper the computed would throw a TypeError during server rendering. With it, the comment is sanitised and rendered in the browser only, so it is missing from the server-rendered HTML and appears once the page has loaded.
If the HTML is stored by your own API, sanitise it in the server route as well, when it is saved. DOMPurify runs in Node when you give it a window from jsdom; its README warns against pairing it with happy-dom. Every client that reads the comment then gets clean HTML, and the sanitising on render becomes a second layer.
Check the scheme of links from visitors
A bound href is the second hole. Vue escapes the attribute value, but it does not judge it. javascript:alert(document.cookie) is a valid attribute value, and clicking the link runs the code.
<script setup lang="ts">
defineProps<{ website: string }>()
</script>
<template>
<a :href="website" rel="noopener">{{ website }}</a>
</template>
Check the scheme (the start of the URL up to the first colon, such as https:) before you bind it. The URL constructor parses the string and tells you the protocol, and an allow list of http:, https: and mailto: covers what a profile link should ever be.
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ website: string }>()
const allowed = new Set(['http:', 'https:', 'mailto:'])
const safeHref = computed(() => {
try {
const url = new URL(props.website)
return allowed.has(url.protocol) ? url.href : null
} catch {
return null
}
})
</script>
<template>
<a v-if="safeHref" :href="safeHref" rel="noopener">{{ website }}</a>
<span v-else>{{ website }}</span>
</template>
A relative URL throws in new URL() without a base, which is right for a field that should hold a full address; the text is still shown, just not as a link.
The Vue guide goes further: if you are checking URLs in the browser, you already have a problem, because the bad value is in your database. Validate on the server when the profile is saved, and keep this check as the second layer.
Keys in client code are public
Anything in the JavaScript bundle can be read by anyone who opens the network tab, including environment variables the build tool inlines. With Vite, every variable prefixed VITE_, whether it comes from a .env file or the shell, is replaced with its value at build time and shipped; the Vite docs say plainly that VITE_* variables should not contain any sensitive information. Nuxt's equivalent is runtimeConfig.public, filled from NUXT_PUBLIC_* variables, and the word "public" is literal: the whole object is serialised into the page.
<script setup lang="ts">
const key = import.meta.env.VITE_WEATHER_KEY
const { data } = await useFetch(
`https://api.example.com/weather?city=Leeds&key=${key}`,
)
</script>
<template>
<p>{{ data }}</p>
</template>
The key is in the bundle, in the request URL and in the weather provider's logs under your account. Whoever finds it spends your quota.
The fix is a server route that holds the key and talks to the API on the browser's behalf. In Nuxt, private runtime config lives outside public and is only available on the server:
- Declare the key in
nuxt.config.ts:runtimeConfig: { weatherKey: '' }. - Give it a value with the environment variable
NUXT_WEATHER_KEY. The runtime config docs give the naming rule:NUXT_, then the key in capitals, with_where the case changes. - Read it in a server route that calls the weather API:
export default defineEventHandler(async (event) => {
const { weatherKey } = useRuntimeConfig(event)
const { city } = getQuery(event)
return $fetch('https://api.example.com/weather', {
query: { city: String(city ?? 'Leeds'), key: weatherKey },
})
})
The component now calls useFetch('/api/weather', { query: { city } }) and never sees the key. In a plain Vue app without a server you need some backend anyway; a tiny proxy function on your host does the same job. There is no client-only way to hide a secret. If it must stay secret, it cannot be downloaded.
Stop forged requests (CSRF) on the backend
Cross-site request forgery (CSRF) abuses the fact that browsers send cookies automatically. If your API authenticates with a cookie, a form on an attacker's site can submit to https://yourapp.com/api/orders, and the browser can attach the cookie.
The defence lives in the backend: set the session cookie with SameSite=Lax (or Strict), which stops browsers sending it on cross-site form posts, and require a token or a custom header on every request that changes data. From the Vue side, your job is to send whatever token the backend asks for. The Vue guide says the same: CSRF is addressed with your backend team, not in a template.
Audit your dependencies
Your bundle is mostly other people's code, so part of your security is theirs. Run npm audit on a schedule. It sends the packages listed in your lock file to the npm registry and lists the known vulnerabilities, how severe they are and whether a fix is available. npm audit fix applies the updates that stay within your version ranges. Fixes that need a new major version wait for npm audit fix --force; the npm audit report names the version --force would install, so read that first.
npm audit
npm audit fix
Commit the lock file, so what you tested is what you deploy, and turn on automated update pull requests from your Git host so patches arrive as small diffs rather than one frightening jump.
Read what a third-party component does with the DOM before you let it render visitor content. A component that uses innerHTML under the hood is a v-html you did not write.
Serve everything over HTTPS
The API needs HTTPS as much as the pages do, and so does the development preview you share with a client. Without it, every cookie, token and form value crosses the network in clear text, and a session cookie with the Secure flag (below) is not sent at all. Hosting platforms usually issue certificates for free.