Frontend security often takes a back seat when shipping features, but Vue applications expose your users to real risk when vulnerabilities slip through. We'll walk you through a Vue security checklist: a practical guide for developers that covers the vulnerabilities we see most often in production codebases, with concrete examples and solutions you can apply today.
Why Vue security matters
Vue runs in the browser, which means every user downloads your entire application logic. Unlike backend services protected by firewalls and authentication layers, frontend code executes in an environment you don't control. A single security oversight—a misused directive, an unvalidated URL, an exposed API key—can compromise user data, enable session hijacking, or turn your application into a vector for malware distribution.
The Vue security checklist exists because these vulnerabilities are preventable. Let's walk through each item.
v-html — Never with untrusted content
The v-html directive renders raw HTML directly into the DOM. It's useful when you control the content entirely, but it's a common injection vector when used with user input or third-party data.
The risk: If you render user-generated content or API data with v-html, an attacker can inject malicious scripts.
// ❌ Dangerous
<template>
<div v-html="userComment"></div>
</template>
<script setup>
const userComment = '<img src=x onerror="fetch(\"/api/steal\", { method: \"POST\", body: JSON.stringify(localStorage) })">';
</script>The attacker-controlled comment executes arbitrary JavaScript in the user's browser, stealing tokens from localStorage.
The solution: Use v-text or text interpolation for user content. Render HTML only when you generate it yourself or use a sanitization library for edge cases.
// ✅ Safe — text interpolation escapes by default
<template>
<div>{{ userComment }}</div>
</template>
// ✅ Safe — v-text also escapes
<template>
<div v-text="userComment"></div>
</template>If you genuinely need to render HTML and it comes from user input, use a sanitization library like dompurify:
import DOMPurify from 'dompurify';
// ✅ Sanitized HTML
<template>
<div v-html="sanitizedContent"></div>
</template>
<script setup>
import { computed } from 'vue';
const userContent = '<img src=x onerror="alert(1)"><p>Safe text</p>';
const sanitizedContent = computed(() => DOMPurify.sanitize(userContent));
</script>DOMPurify strips dangerous attributes and scripts while preserving safe HTML structure. Pair it with a Content Security Policy (CSP) header for defense in depth.
URLs — Validate user-provided links
Links are everywhere: navigation, external resources, redirects. When you construct URLs from user input without validation, you enable open redirect attacks or JavaScript execution via data URIs.
The risk: An attacker crafts a malicious URL and tricks a user into clicking it. The application then navigates to that URL, stealing credentials or distributing malware.
// ❌ Dangerous — no validation
<template>
<a :href="redirectUrl">Complete your account setup</a>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
const redirectUrl = route.query.return_to; // User controls this
</script>An attacker sends a link like ?return_to=https://evil.com, and users click the seemingly safe link from your domain, only to be redirected to a phishing site.
The solution: Validate URLs against a whitelist of safe origins or safe protocols. Use URL parsing to prevent bypasses.
// ✅ Safe — protocol and origin validation
<template>
<a :href="safeRedirectUrl">Complete your account setup</a>
</template>
<script setup>
import { useRoute } from 'vue-router';
import { computed } from 'vue';
const route = useRoute();
const SAFE_REDIRECT_HOSTS = new Set([
'example.com',
'app.example.com'
]);
const safeRedirectUrl = computed(() => {
const returnTo = route.query.return_to;
if (!returnTo || typeof returnTo !== 'string') return '/dashboard';
try {
const url = new URL(returnTo, window.location.origin);
// Only allow https and same-origin redirects
if (url.protocol !== 'https:') return '/dashboard';
if (!SAFE_REDIRECT_HOSTS.has(url.hostname)) return '/dashboard';
return url.toString();
} catch {
return '/dashboard'; // Invalid URL
}
});
</script>For internal router redirects, use route names instead of user-controlled strings:
// ✅ Safer — typed route names
<script setup>
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
const handleRedirect = () => {
// Map only allowed routes
const allowedRoutes: Record<string, string> = {
'dashboard': 'Dashboard',
'profile': 'Profile'
};
const target = route.query.return_to as string;
const routeName = allowedRoutes[target];
if (routeName) {
router.push({ name: routeName });
} else {
router.push({ name: 'Dashboard' });
}
};
</script>Secrets — Nothing secret in client bundles
Your Vue application is bundled and shipped to every user's browser. Anything in the bundle—environment variables, API keys, tokens—can be read by anyone. This includes private keys, database credentials, and authentication tokens for third-party services.
The risk: Developers often hardcode secrets, thinking they're hidden because the code is minified. An attacker inspects network requests, reads the bundle, or checks your git history and finds credentials that grant access to production systems.
// ❌ Dangerous — secret in code
<script setup>
const STRIPE_SECRET_KEY = 'sk_live_...'; // This is in the bundle!
const API_URL = 'https://api.example.com';
const createPayment = async (amount: number) => {
const response = await fetch(API_URL + '/payments', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + STRIPE_SECRET_KEY },
body: JSON.stringify({ amount })
});
};
</script>The solution: Keep secrets on the backend. Use public API keys only if necessary, and never store private keys in frontend code. Pass sensitive operations through your backend API.
// ✅ Safe — secrets stay on backend
<script setup>
// Only public key, and rate-limited via CSP and backend
const STRIPE_PUBLIC_KEY = import.meta.env.VITE_STRIPE_PUBLIC_KEY;
const API_URL = import.meta.env.VITE_API_URL;
const createPayment = async (amount: number) => {
// Backend handles Stripe secret key
const response = await fetch(API_URL + '/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount })
});
return response.json();
};
</script>For third-party integrations like Stripe, use their public API in the frontend and proxy sensitive operations through your backend. Your backend authenticates with the service using its secret key, never exposed to the client.
Use environment variables prefixed with VITE_ in Vite projects, and ensure your CI/CD pipeline loads secrets from a secure vault, not git history.
Dependencies — Audit packages regularly
Your Vue project depends on hundreds of packages, each with its own supply chain risk. A vulnerability in a deeply nested dependency can compromise your entire application. You might not even know which packages you're using.
The risk: A developer publishes a malicious version of a popular package, or a legitimate package has an unpatched vulnerability. Your application pulls in compromised code automatically.
The solution: Audit your dependencies regularly, lock versions, and keep packages updated.
// Run npm audit to find known vulnerabilities
npm audit
// Fix automatically where possible
npm audit fix
// Force a specific update
npm update
// Review package-lock.json in git
// Pin exact versions in production
{
"dependencies": {
"vue": "3.4.0",
"axios": "1.6.0"
}
}Set up automated dependency scanning in your CI/CD pipeline:
// Example GitHub Actions workflow
name: Audit Dependencies
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm audit --audit-level=moderate
Use tools like snyk or dependabot to automatically open pull requests for outdated packages. Review changelogs before upgrading major versions—breaking changes sometimes exist for security reasons.
Be cautious with transitive dependencies. Use npm ls to understand your dependency tree, and consider using a lock file format that you commit and review.
Common mistakes
- Trusting data from the browser. Users can modify any data sent from the frontend: query parameters, form fields, localStorage. Always validate on the backend.
- Assuming minification equals obfuscation. Minified code is still readable to anyone with a dev tools console. Don't store secrets in the bundle.
- Ignoring CSP headers. A strong Content Security Policy blocks inline scripts and restricts where resources can be loaded. Configure it on your backend alongside frontend practices.
- Storing tokens in localStorage. XSS attacks can steal localStorage data. Use HTTP-only, Secure cookies where possible, or a secure session backend.
- Skipping dependency updates. Old packages accumulate vulnerabilities. Plan regular update cycles into your sprint.
Key takeaways
- Never use
v-htmlwith untrusted content; sanitize with libraries like DOMPurify if needed, or rely on text interpolation. - Validate all user-provided URLs against a whitelist of safe origins and protocols; prefer typed route names for internal navigation.
- Keep secrets on the backend; never hardcode API keys, tokens, or credentials in frontend code or environment variables exposed to the bundle.
- Audit dependencies regularly with
npm auditand integrate automated scanning into CI/CD; lock versions and review changes before updates. - Combine frontend security practices with backend validation and a strong Content Security Policy for defense in depth.






Comments
Loading comments…