Security risks in Vue.js projects and how to close them

Five vulnerabilities that show up in Vue applications, with the fixes for each.

Deploying a new Vue application usually comes with a small amount of unease. Did I miss something that could expose user data or compromise the app?

We spend hours on interfaces, performance and features, and security often ends up last in the queue. The reasoning is usually "it's just a frontend, what could go wrong". Quite a lot, as it turns out. Vue itself is secure, but how we use it and what we pull in around it is where vulnerabilities come from.

Below are five common risks in Vue projects and what to do about each.

Why frontend security matters

You could argue security is mostly a backend problem, since the server handles authentication, database access and everything sensitive. Backend security is critical, true. But the frontend is the user's direct interface with your application, and when it's compromised attackers can steal credentials through phishing or keyloggers, manipulate data such as prices in a shop, trigger actions the user never intended if your API validates poorly, or simply deface the site.

1. Cross-site scripting

XSS is when an attacker injects a script, usually JavaScript, that then runs in the victim's browser. From there it can take cookies and session tokens or rewrite the page.

It usually happens when an application renders user input straight into the DOM without sanitising it. Picture a comment section where someone submits this:

<p>This is a great article!</p>

Harmless. Now this:

<p>
  This is a great article!
  <script>
    alert("You've been XSSed!");
  </script>
</p>

If your component binds that unsanitised input with v-html, the script runs for everyone who views the comment.

The rule to remember: never use v-html with untrusted user content. With standard interpolation, Vue escapes HTML by default.

<!-- Safe: Vue escapes the HTML -->
<template>
  <div>
    <p>{{ userComment }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const userComment = ref('<script>alert("XSS attempt!");</script>');
</script>

That renders the script tag as plain text rather than executable code.

If you genuinely need to render dynamic HTML, from a rich text editor for example, sanitise the input on the backend first with a library such as DOMPurify before storing it, and sanitise again on the frontend as a second layer before passing anything to v-html.

<!-- Use v-html ONLY with trusted, sanitized content -->
<template>
  <div v-html="sanitizedUserContent"></div>
</template>

<script setup>
import { ref, computed } from 'vue';
// In a real app, you'd get this from a backend API after sanitization
const rawUserContent = ref('<p>Hello <b>World</b>! <script>alert("No!");</script></p>');

// For demonstration, let's pretend this is sanitized
// In a real app, you'd use a proper library like DOMPurify
const sanitizedUserContent = computed(() => {
  // This is a VERY simplistic and insecure "sanitization" for example purposes.
  // DO NOT use this in production. Use a library like DOMPurify!
  return rawUserContent.value.replace(/<script.*?>.*?<\/script>/g, '');
});
</script>

2. Cross-site request forgery

CSRF tricks a logged-in user into performing an action they didn't intend. The attacker gets the user's browser to send a request to your server, relying on the fact that the user is already authenticated.

Say you're logged into your bank in one tab and open a malicious site in another. That site contains a hidden form or an image tag pointing at the bank's transfer endpoint:

<!-- Malicious website's hidden content -->
<img
  src="https://yourbank.com/transfer?to=attacker&amount=1000000"
  style="display:none;"
/>

When the browser loads that image it sends a GET request along with your session cookies. If the API has no CSRF protection, it may treat that as a legitimate request.

The usual defence is a CSRF token. The server generates an unpredictable token and sends it to the client, in a cookie, a meta tag or the initial payload. Every state-changing request from your Vue app, meaning POST, PUT and DELETE, includes that token in the headers or body. The backend rejects anything where the token doesn't match.

In practice you configure your HTTP client to attach it automatically.

// Example using Axios (adjust based on how your backend provides the token)
import axios from "axios";

// 1. Get the CSRF token (e.g., from a meta tag in your HTML, or a cookie)
function getCsrfToken() {
  // This is an example, your backend might provide it differently
  const tokenElement = document.querySelector('meta[name="csrf-token"]');
  return tokenElement ? tokenElement.getAttribute("content") : "";
}

const csrfToken = getCsrfToken();

// 2. Configure Axios to include the token in requests
if (csrfToken) {
  axios.defaults.headers.common["X-CSRF-TOKEN"] = csrfToken;
}

// Now, when you make a POST, PUT, or DELETE request, the token will be included
axios.post("/api/transfer-money", {
  recipient: "myfriend",
  amount: 50,
});

GET requests generally don't need tokens because they're supposed to be idempotent. If your API changes state on a GET, that's a separate problem worth fixing.

3. Vulnerable dependencies

Your node_modules folder is a large amount of other people's code. Most of it is fine, and one vulnerable package is enough to open a door. Either a package you depend on, or something in its own dependency tree, has a known flaw, or a maintainer's account is compromised and malicious code arrives in an update.

Audit regularly. Both npm audit and yarn audit check installed packages against known vulnerabilities and usually suggest fixes.

    # npm audit report

    lodash  <4.17.21
    Severity: high
    Prototype Pollution - https://npmjs.com/advisories/1526
    No fix available

    axios  <0.21.1
    Severity: moderate
    Arbitrary File Write via Decompress - https://npmjs.com/advisories/1608
    Fix available: `npm install axios@^0.21.1`
    ```

Before adding a dependency, look at whether it's actively maintained, how many people use it, whether there are open security issues and when it was last updated.

Commit your `package-lock.json` or `yarn.lock`. Those files pin exact versions so your team and your CI pipeline all build against the same tested set.

## 4. Leaking secrets into the bundle

This one sounds obvious, and it still happens regularly. API keys hardcoded in components or JavaScript files end up in the client bundle, which makes them public. Detailed error messages leak information about your backend. Environment variables that were never meant for the client get bundled in.

Never hardcode secrets in frontend code. If the frontend needs an API that requires a key, proxy it: your Vue app calls your backend, and your backend calls the third-party API with the key. So instead of `axios.get('https://api.thirdparty.com/data?apiKey=YOUR_SECRET_KEY')` from the browser, the request goes through a server you control.

Environment variables need the same care. Vue CLI exposes anything prefixed with `VUE_APP_` to the client bundle, and Vite does the same with `VITE_`. Put API endpoints there, not API keys. Keep production secrets out of version control and manage them through your hosting provider's configuration.

Handle errors so they don't leak either. Raw backend errors can reveal database schema or server paths, so show the user something generic and log the detail on the server.

```html
<template>
  <div>
    <button @click="fetchData">Fetch Data</button>
    <p v-if="error">{{ errorMessage }}</p>
    <div v-if="data">{{ data }}</div>
  </div>
</template>

<script setup>
  import { ref } from "vue";
  import axios from "axios";

  const data = ref(null);
  const error = ref(false);
  const errorMessage = ref("");

  const fetchData = async () => {
    try {
      const response = await axios.get("/api/sensitive-data");
      data.value = response.data;
      error.value = false;
    } catch (err) {
      error.value = true;
      // Don't show the raw error to the user!
      // console.error(err.response.data); // Bad practice
      errorMessage.value =
        "Oops! Something went wrong. Please try again later.";
      // Log the detailed error to a server-side logging service
    }
  };
</script>

Assume everything in your compiled frontend is public, because it is.

5. Authentication and authorisation on the client

This isn't specific to Vue, but it comes up constantly. Enforcing auth purely on the frontend is a bouncer at the front door with the back door open.

It shows up as hiding UI based on a role, assuming the backend will check permissions anyway, or storing an isAdmin flag in Pinia or Vuex and treating it as authoritative.

Every endpoint that requires authentication should verify the token or session, and every endpoint that requires a permission should check that permission before doing anything. The frontend is there for user experience: hiding a button someone can't use is reasonable, but it isn't security, and anyone can bypass your JavaScript.

Store tokens carefully. HttpOnly cookies set by the backend keep tokens out of reach of client-side JavaScript, which limits the damage of an XSS bug. If you use localStorage, understand that you're relying entirely on having no XSS anywhere.

// BAD: Relying on client-side state for authorization
// In a real app, an attacker could manipulate `isAdmin` in their browser.
const user = {
  name: "Alice",
  isAdmin: false, // Imagine an attacker changing this to true in the console
};

if (user.isAdmin) {
  // Show admin button - this is for UX, not security!
}

// GOOD: Backend always validates permissions
async function deleteUser(userId) {
  try {
    // Frontend sends the request
    await axios.delete(`/api/users/${userId}`);
    alert("User deleted!");
  } catch (error) {
    // Backend rejects if user is not authorized
    if (error.response && error.response.status === 403) {
      alert("You are not authorized to perform this action.");
    } else {
      alert("An error occurred.");
    }
  }
}

In short

Vue gives you a secure foundation and the rest is on us. Sanitise user input, especially anything approaching v-html. Implement CSRF tokens with your backend. Audit and update dependencies. Keep secrets out of the bundle. Validate authentication and authorisation on the server, every time.

Related posts