OWASP Top 10 Security Priorities for Vue.js Developers

Vue.jsSecurityOWASPJavaScriptFrontend SecurityWeb Security
By , Senior Full-Stack EngineerUpdated 14 min read

The OWASP Top 10 is a list of web application risks, and most of it is about servers. That makes it an awkward fit for a frontend framework. The useful way to read it as a Vue developer is to sort each risk into one of three buckets: things you fix in your Vue code, things you can only reduce from the browser, and things that live entirely on your backend.

This guide follows the 2025 edition of the list. Each section covers what the risk looks like in a Vue 3 app, the Composition API patterns that help, and where the real control has to live. The 2025 edition reshuffled the order, folded server-side request forgery (SSRF) into access control, and added two categories that matter more to frontend teams than the old ones did: software supply chain failures and the handling of exceptional conditions.

A01: Broken Access Control

Broken access control is still number one. In a Vue app it usually shows up as a route or a button that's hidden from the wrong users but not actually protected.

Route guards are worth having, because they keep people out of screens they can't use. They're not a security control. Anyone can call your API with curl, so every permission check has to happen again on the server.

// composables/useAuth.js
// SPA-only pattern. For SSR (Nuxt, Vike, etc.) move state into a per-request
// store like Pinia, because module-level refs leak across requests.
import { ref } from 'vue'

const user = ref(null)
const isAuthenticated = ref(false)

export function useAuth() {
  const hasRole = (role) => user.value?.roles?.includes(role) ?? false
  return { user, isAuthenticated, hasRole }
}
// router/index.js
router.beforeEach((to) => {
  const { isAuthenticated, hasRole } = useAuth()

  if (to.meta.requiresAuth && !isAuthenticated.value) return '/login'
  if (to.meta.requiresRole && !hasRole(to.meta.requiresRole)) return '/unauthorized'
})

SSRF now lives in this category too, and it's a backend problem. If your app lets users submit a URL that the server later fetches (an avatar from a link, a webhook target, a link preview), the server has to validate it: resolve the hostname once, refuse private and link-local addresses in both IPv4 and IPv6, and connect to the address it checked. An allowlist in the Vue app catches typos. It doesn't stop an attacker, who will skip the browser entirely.

In practice:

  • Enforce every permission on the server; treat route guards and hidden buttons as UX.
  • Use relative URLs for your own API so a misconfigured base URL can't send credentials elsewhere.
  • Never pass a user-supplied URL to a backend that fetches it without server-side validation.

A02: Security Misconfiguration

Misconfiguration moved up to second place. For a Vue app, the common versions are a production build that ships debugging aids and secrets that end up in the bundle.

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => ({
  plugins: [vue()],
  define: {
    // Keep Vue devtools support and verbose hydration details out of production
    __VUE_PROD_DEVTOOLS__: false,
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
  },
  build: {
    // Don't hand your source to anyone who opens devtools in production
    sourcemap: mode !== 'production',
  },
}))

Two things trip people up here:

  • Every VITE_ variable is public. Vite only exposes variables with the VITE_ prefix to client code, and it inlines their values into the bundle in plain text. API keys, signing secrets, and database URLs never belong in one. Keep them in server-only config. (Vue CLI's VUE_APP_ prefix is the same story with a different name.)
  • server.headers in the Vite config only applies to the dev server. Your production security headers (Content-Security-Policy, HSTS, X-Content-Type-Options, frame-ancestors) come from whatever serves the built files: your CDN, Nginx, or your Node server. Skip the legacy X-XSS-Protection header; modern browsers ignore it, and CSP is what actually blocks XSS.

In practice:

  • Check a production bundle for strings that look like keys before every release.
  • Set security headers where production traffic is served, and test them against the live site.
  • Show users generic error messages; keep details in server logs.

A03: Software Supply Chain Failures

Supply chain failures are new to the list. The category expands 2021's "vulnerable and outdated components" to cover compromised packages, build systems, and update channels, which is exactly where npm worms like Shai-Hulud have been hitting. A Vue app pulls in hundreds of transitive dependencies, and a single malicious version can run code on every machine that installs it.

The defenses are configuration, not code:

  • Commit your lockfile and install from it. npm ci, pnpm install --frozen-lockfile, or yarn install --immutable in CI, so builds never pick up a version nobody reviewed. I go through the setup in the locked-dependencies post.
  • Add a release-age cooldown. pnpm, npm, Yarn, and Bun can all refuse versions published in the last few days, which is longer than most malicious versions stay on the registry.
  • Limit install scripts. Most npm malware runs from a postinstall script. pnpm only runs lifecycle scripts for packages you allow. Here's how those three layers held up when 84 malicious TanStack versions hit npm.
  • Audit in CI, and fail on high-severity findings.
# .github/workflows/security-check.yml
name: Security Check
on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm audit --omit=dev --audit-level=high
      - run: npm run build

A04: Cryptographic Failures

Cryptographic failures on the frontend are mostly about where sensitive data sits. Anything in localStorage or sessionStorage is readable by any script on the page, so one XSS bug hands over every token stored there.

Keep session tokens in HttpOnly, Secure, SameSite cookies that JavaScript can't read, and let the browser send them:

// The session cookie is set by the server and is HttpOnly + Secure + SameSite=Lax.
// The Vue app never sees or stores the token itself.
const response = await fetch('/api/account', {
  credentials: 'include',
})

In practice:

  • Serve everything over HTTPS, and send Strict-Transport-Security from the server.
  • Never put passwords, refresh tokens, or session tokens in web storage.
  • Don't implement crypto in the browser to "protect" data you then store there. If the client can decrypt it, so can an attacker running code on the page.

A05: Injection

Injection in a Vue app mostly means cross-site scripting. Vue escapes text interpolation for you, so {{ userInput }} is safe. The Vue security guide lists the places it can't protect you, and two of them come up constantly.

v-html renders raw HTML. Avoid it for anything a user wrote. When you genuinely need it, sanitize first:

<template>
  <!-- Safe: Vue escapes interpolation -->
  <p>{{ comment.author }}</p>

  <!-- Raw HTML only after sanitizing -->
  <div v-html="safeBody"></div>
</template>

<script setup>
import { computed } from 'vue'
import DOMPurify from 'dompurify'

const props = defineProps({ comment: { type: Object, required: true } })

const safeBody = computed(() =>
  DOMPurify.sanitize(props.comment.body, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'a'],
    ALLOWED_ATTR: ['href'],
  }),
)
</script>

URL bindings aren't sanitized. <a :href="profile.website"> happily renders a javascript: URL, which runs when someone clicks it. Check the protocol before binding a URL a user supplied:

// utils/safeUrl.js
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])

export function safeUrl(value) {
  try {
    const url = new URL(value, window.location.origin)
    return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : '#'
  } catch {
    return '#'
  }
}

In practice:

  • Treat every v-html as a review item, and sanitize with DOMPurify or an equivalent.
  • Run user-supplied URLs through a protocol check before binding them.
  • Add a Content Security Policy so an injection that slips through can't load or run arbitrary scripts.
  • Validate input on the server too. Client-side validation is for the user's benefit, not yours.

A06: Insecure Design

Insecure design covers flaws no amount of careful coding fixes, because the control was never designed in. For a Vue app, cross-site request forgery is the classic example: a state-changing form with no protection against being submitted from another site.

The primary defense is the session cookie's SameSite=Lax (or Strict) attribute, set by the server. A CSRF token is the second layer:

// composables/useSecureForm.js
import { ref } from 'vue'

export function useSecureForm(endpoint) {
  const isSubmitting = ref(false)
  const errors = ref({})
  const csrfToken = ref('')

  const init = async () => {
    const response = await fetch('/api/csrf-token', { credentials: 'include' })
    csrfToken.value = (await response.json()).token
  }

  const submit = async (formData) => {
    if (isSubmitting.value) return
    isSubmitting.value = true
    errors.value = {}

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken.value,
        },
        body: JSON.stringify(formData),
      })

      if (!response.ok) {
        errors.value = (await response.json()).errors ?? {}
        throw new Error('Form submission failed')
      }
      return await response.json()
    } finally {
      isSubmitting.value = false
    }
  }

  return { isSubmitting, errors, init, submit }
}

In practice:

  • Decide where each security control lives before building the feature, not after.
  • Give each role the narrowest access that works, and default new features to "off" for everyone.
  • Rate-limit anything that sends email, creates accounts, or costs money per call, on the server.

A07: Authentication Failures

Authentication failures are a server responsibility, but the Vue side can either help or get in the way. The pattern below keeps the session in an HttpOnly cookie and treats lockout as something the server enforces. The client only respects the server's Retry-After header so it stops offering a button that won't work.

// composables/useAuthentication.js
// Client-side cooldown is a UX nicety, NOT a security control. The server
// enforces per-account and per-IP lockout; anyone using curl bypasses this.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'

const user = ref(null)
const isLoading = ref(false)
const cooldownUntil = ref(null)

export function useAuthentication() {
  const router = useRouter()
  const isAuthenticated = computed(() => !!user.value)
  const inCooldown = computed(
    () => cooldownUntil.value !== null && Date.now() < cooldownUntil.value,
  )

  const login = async ({ email, password, captchaToken }) => {
    if (inCooldown.value) throw new Error('Too many attempts. Try again later.')
    isLoading.value = true

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include', // session cookie: HttpOnly + Secure + SameSite=Lax
        body: JSON.stringify({ email, password, captchaToken }),
      })

      if (response.status === 429) {
        const retryAfter = Number(response.headers.get('Retry-After')) || 900
        cooldownUntil.value = Date.now() + retryAfter * 1000
        throw new Error('Too many attempts. Try again later.')
      }
      if (!response.ok) throw new Error('Invalid credentials')

      user.value = (await response.json()).user
      cooldownUntil.value = null
      router.push('/dashboard')
    } finally {
      isLoading.value = false
    }
  }

  const logout = async () => {
    try {
      await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
    } finally {
      user.value = null
      router.push('/login')
    }
  }

  return { isAuthenticated, isLoading, inCooldown, login, logout }
}

In practice:

  • Offer phishing-resistant MFA: passkeys where you can, TOTP as the fallback.
  • Use the same error message for "no such user" and "wrong password."
  • Expire sessions on the server, and clear client state on logout even if the request fails.

A08: Software or Data Integrity Failures

Integrity failures happen when code or data is trusted without being checked. For a Vue app, the two places that matter are third-party scripts and your build pipeline.

Your bundled code is served from your own origin, so the browser trusts it as much as it trusts your server. A script loaded from someone else's CDN is different. Pin it with Subresource Integrity, so the browser refuses it if the file changes:

<script
  src="https://cdn.example.com/widget/2.4.1/widget.min.js"
  integrity="sha384-REPLACE_WITH_THE_HASH_PUBLISHED_FOR_THIS_EXACT_FILE"
  crossorigin="anonymous"
></script>

The pipeline is the bigger risk. The worm that published malicious TanStack versions started with a pull request workflow that ran with publish rights and restored a poisoned GitHub Actions cache. Never give workflows triggered by outside pull requests access to secrets or publish tokens, and require review before anything merges to the branch you deploy from.

In practice:

  • Use SRI for every script and stylesheet loaded from a third-party origin.
  • Protect your main branch and require reviews.
  • Keep publish and deploy credentials out of workflows that run untrusted code.

A09: Security Logging and Alerting Failures

The 2025 edition renamed this category from "monitoring" to "alerting," and the change is the point: a log nobody gets alerted from doesn't stop anything.

Frontend telemetry helps, but treat it as supplementary. Everything the browser reports is under the user's control, including the URL and the user agent. The authoritative security log lives on the server, where it can record the real IP and the authenticated user from the session.

// composables/useSecurityLogger.js
// Frontend telemetry only. The server resolves the user from the session
// cookie; never trust a userId the client sends.
export function useSecurityLogger() {
  const logSecurityEvent = (type, details = {}) =>
    fetch('/api/security/log', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        type,
        details,
        url: window.location.pathname,
        timestamp: new Date().toISOString(),
      }),
    }).catch(() => {
      // Logging must never break the app
    })

  return { logSecurityEvent }
}

In practice:

  • Log authentication events and access-control failures on the server, with the real IP and user.
  • Send logs somewhere durable (Sentry, Datadog, CloudWatch, Loki), not just local files.
  • Alert on patterns that matter, like a burst of 401s or 403s from one account or address, and write down who responds.

A10: Mishandling of Exceptional Conditions

This category is new in 2025. It covers improper error handling, logic errors, and "failing open": code that grants access, skips a check, or shows sensitive detail when something unexpected happens. Frontend code hits these conditions constantly, because every network call can fail.

Two habits cover most of it in Vue. First, fail closed. When the call that decides what a user may see fails, the answer should be "nothing privileged," not "whatever was there before":

// composables/usePermissions.js
import { ref } from 'vue'

export function usePermissions() {
  // Empty means "no access", never "unknown"
  const permissions = ref([])
  const loadError = ref(null)

  const load = async () => {
    try {
      const response = await fetch('/api/me/permissions', { credentials: 'include' })
      if (!response.ok) throw new Error(`Permissions request failed: ${response.status}`)
      permissions.value = await response.json()
    } catch (error) {
      permissions.value = [] // fail closed: hide privileged UI on any error
      loadError.value = error
    }
  }

  const can = (permission) => permissions.value.includes(permission)

  return { permissions, loadError, load, can }
}

Second, catch errors in one place, report them, and show the user something generic. A global handler means an unexpected exception never leaves the app half-rendered or shows a stack trace:

// main.js
app.config.errorHandler = (error, instance, info) => {
  // Report to your error tracker. Never render error.message to the user.
  reportError(error, { info })
}

For a single section of the page, onErrorCaptured in a wrapper component works the same way: catch the error, show a fallback, and stop it from taking down the rest of the page.

In practice:

  • Default every security decision to "deny" when data is missing, late, or malformed.
  • Handle rejected promises everywhere you await; an unhandled one is an unplanned state.
  • Show generic messages to users and keep the details in your error tracker.

The Short Version

Sorted by where the fix lives, the list gets shorter for a Vue developer. The things that are mostly yours to get right in Vue code are injection through v-html and URL bindings, secrets that leak through VITE_ variables, what the UI does when a request fails, and the dependencies you install. Access control, authentication, CSRF, SSRF, and logging all need the backend to do the real work. The Vue side's job there is to stay out of the way: keep tokens out of web storage, respect the server's answers, and never pretend a route guard is a security boundary.

Contact

Drop me a line. I read everything and reply within one business day.

Required fields are marked “(required)”.