The OWASP Top 10 is a list of the ten most serious categories of risk in web applications, kept by the Open Worldwide Application Security Project and rebuilt every few years from testing data. It is written for every language and framework at once, so it does not say what broken access control looks like in a Nuxt server route, or where cross-site scripting hides in a Vue template. Below, each category of the 2025 edition gets that translation and one concrete action.
The examples use Nuxt 4.2, with the client code in app/ and server routes in server/ at the project root. Everything about the browser side applies to a plain Vue and Vite app too.
The 2025 list and what changed since 2021
The release candidate ranks the categories like this:
- A01 Broken Access Control, first again. Server-Side Request Forgery (SSRF), a separate entry in 2021, is now folded into it.
- A02 Security Misconfiguration, up from fifth.
- A03 Software Supply Chain Failures, a new name that grows out of 2021's Vulnerable and Outdated Components to cover build systems and distribution as well as libraries.
- A04 Cryptographic Failures, down from second.
- A05 Injection, down from third.
- A06 Insecure Design, down from fourth.
- A07 Authentication Failures, still seventh, renamed from Identification and Authentication Failures.
- A08 Software or Data Integrity Failures, still eighth.
- A09 Logging and Alerting Failures, still ninth, renamed from Security Logging and Monitoring Failures to stress that a log nobody is alerted by has little value.
- A10 Mishandling of Exceptional Conditions, new: error handling, failing open and logic that breaks under unusual input.
The introduction says the data covers more than 2.8 million applications and 589 CWEs. A CWE, a Common Weakness Enumeration entry, is a numbered type of bug; the ten categories group 248 of them, from 5 to 40 each. Eight categories were picked from the data. The other two, A03 and A09, came from a community survey, because testing tools under-report them.
Enforce every check on the server
Every line of a Vue app is delivered to the user. They can read it, change it and call your API without it. That one fact decides most of what follows: Vue code can make the interface honest and pleasant, but every check that matters has to run on the server. Here is the most common mistake, in the shape it usually takes.
<script setup lang="ts">
const { data: user } = await useFetch('/api/me')
async function removeUser(id: number) {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
}
</script>
<template>
<button v-if="user?.role === 'admin'" @click="removeUser(42)">Delete user</button>
</template>
The button is hidden for everyone else. The endpoint is not. Anyone can open the console and send the same DELETE request. That is A01 in its purest form, and the OWASP page for A01 lists exactly this scenario: access checks that exist only in JavaScript.
The fix belongs in the server route, and it starts by denying by default: the route refuses the request unless the session shows an admin.
export default defineEventHandler(async (event) => {
const user = await getSessionUser(event)
if (user?.role !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
}
const id = getRouterParam(event, 'id')
await deleteUser(Number(id))
return { ok: true }
})
getSessionUser and deleteUser are your own helpers; the shape is what matters. Keep the v-if too, so the interface does not advertise buttons that will fail, but never count it as a control.
A01 to A03: access control, configuration and dependencies
A01 Broken Access Control. Beyond the hidden button above, it covers:
- reading another user's record by changing an id in the URL
POST,PUTandDELETEhandlers with no access check- permissive CORS settings that let untrusted sites call your API
- SSRF, the folded-in case that matters to Nuxt: a server route that fetches a URL taken from the request can be pointed at internal services
Action: every route under server/api/ that touches a record checks that the current user owns it or has the role. Any route that fetches a user-supplied URL checks the host against an allow list first.
A02 Security Misconfiguration. In a Nuxt app this is a secret that ends up in the browser, a debug flag left on, or missing security headers. The line between private and public is runtimeConfig in nuxt.config.ts.
export default defineNuxtConfig({
runtimeConfig: {
paymentApiKey: '', // server only; set with NUXT_PAYMENT_API_KEY
public: {
apiBase: '/api', // shipped to the browser
},
},
})
Anything outside public never reaches the client. Action: search your project for NUXT_PUBLIC_ and confirm each one is meant to be public, then add security headers (Content-Security-Policy, X-Frame-Options) through routeRules or the nuxt-security module.
A03 Software Supply Chain Failures. Half the survey's respondents ranked this first, and September showed why: a self-replicating worm called Shai-Hulud published poisoned versions of hundreds of npm packages using stolen maintainer tokens. A Vue project pulls in several hundred packages. Action: commit the lockfile, install from it in CI, and run the audit on a schedule.
npm ci # installs exactly what package-lock.json says
npm audit # lists known vulnerabilities in the tree
With pnpm the equivalent is pnpm install --frozen-lockfile. Remove packages you no longer use; each one is a maintainer whose account you now depend on.
A04 to A06: cryptography, injection and design
A04 Cryptographic Failures. In the browser, this is cryptography done with a key that ships in the bundle, which protects nothing. On the server, it is a password stored with a fast hash. Action: serve everything over HTTPS, hash passwords on the server with a slow, salted function such as Node's crypto.scrypt, and generate tokens with crypto.randomUUID() or randomBytes, never with Math.random().
A05 Injection. For Vue this is cross-site scripting (XSS), where user-supplied text is rendered as HTML and runs as script. Mustache interpolation ({{ }}) escapes text; v-html does not.
<script setup lang="ts">
defineProps<{ text: string }>()
</script>
<template>
<!-- Escaped. Safe for any string. -->
<p>{{ text }}</p>
<!-- Raw HTML. Only for content you produced, never for user input. -->
<div v-html="text" />
</template>
Actions:
- Grep for
v-htmland justify every match. If you must render user HTML, run it through a sanitiser such as DOMPurify first. - On the server, every database call uses parameters, never string concatenation.
- An
hrefbound to user input needs a check that it starts withhttps://orhttp://, becausejavascript:is a valid URL scheme too.
A06 Insecure Design. This is the category for flaws that no library fixes: a password reset that reveals whether an email exists, a discount code with no usage limit, an export endpoint with no size cap. Action: before you build a feature, write three sentences about how someone could abuse it. That is a threat model, and it is enough at the start.
A07 to A10: authentication, integrity, logging and errors
A07 Authentication Failures. The common Vue mistake is a token in localStorage, readable by any script that gets onto the page. Sessions belong in an httpOnly cookie, which JavaScript cannot read.
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
const session = await signIn(email, password) // your own check, with a slow hash
setCookie(event, 'session', session.token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 8,
})
return { ok: true }
})
Action: move any token out of localStorage, rate-limit the login route, and use a provider for multi-factor authentication rather than building it.
A08 Software or Data Integrity Failures. This is trusting code or data without checking where it came from: a script from a CDN, a configuration object fetched at runtime, a build whose inputs nobody pinned. Action: add integrity and crossorigin="anonymous" to any third-party <script> you load (both are plain attributes in useHead), and build in CI from the lockfile rather than on a laptop.
A09 Logging and Alerting Failures. The new word in the name, alerting, is the point. Action: log every failed login and every 403 on the server with the user id, the route and the time, and set an alert for repeats from one source. Nitro's error hook in a server/plugins/ file is a good single place to send server errors to your log service: every error a route throws passes through it, including a 403 from createError.
A10 Mishandling of Exceptional Conditions. New, and very relevant to server routes: an unhandled exception that returns a stack trace, a check that fails open when a dependency is down, a half-finished transaction. Action: validate input at the top of every handler, catch what you expect, and throw createError with a message safe to show. In this route, saveTodo stands for your own database call.
export default defineEventHandler(async (event) => {
const body = await readBody(event)
if (typeof body?.title !== 'string' || body.title.length > 200) {
throw createError({ statusCode: 400, statusMessage: 'Invalid todo' })
}
try {
return await saveTodo(body.title)
} catch (err) {
console.error('saveTodo failed', err)
throw createError({ statusCode: 500, statusMessage: 'Could not save the todo' })
}
})
The browser sees "Could not save the todo"; your log sees the real error. Permission checks should fail closed in the same way: if the session lookup throws, the answer is 403, not "carry on".
Start with these five steps
Do these in order. In a small app, each one takes under an hour.
- Open every file under
server/api/and find the line that checks who is asking. If a route has none, add it. - Grep for
v-htmlandlocalStorage. Justify or remove each match. - Run
npm audit, commit the lockfile, and switch CI tonpm ci. - Read
nuxt.config.tsand confirm nothing underruntimeConfig.publicis a secret. - Add a global error log on the server and an alert for repeated
401and403responses.