• Thu, Jun 2026

Vue.js Authentication & Role-Based Access Control (RBAC) — Practical Guide

Vue.js Authentication & Role-Based Access Control (RBAC) — Practical Guide

Welcome — this guide shows you how to implement robust authentication and role-based access control (RBAC) in Vue.js apps. I’ll walk you through concepts (JWT, cookies, refresh tokens), show practical code (axios + Pinia + router guards), and give production-minded best practices. I’ve broken it down so you can copy/paste and get a working example quickly.

Why authentication & RBAC matter

Authentication verifies identity. Authorization determines what an authenticated user can do. For many apps, role-based access control (RBAC) — where users have roles (e.g. user, editor, admin) — is a clean and scalable way to enforce permissions. Get this right and you avoid security surprises later.

High-level architecture

Typical architecture for a secure Vue app:

  1. Frontend collects credentials and calls authentication API.
  2. Server authenticates, returns an access token (short-lived) and a refresh token (longer-lived).
  3. Frontend stores access token (memory) or uses secure HttpOnly cookie for tokens; refresh token kept in HttpOnly cookie.
  4. Frontend includes access token in API calls (Authorization header) or server reads cookie.
  5. When access token expires, frontend requests a new one using the refresh token.
  6. Authorization (RBAC) enforced by server on APIs and mirrored on frontend via route guards and UI checks.

Quick practical note: For demos I sometimes show localStorage usage (because it's simple), but in production prefer HttpOnly, Secure cookies for refresh tokens and short-lived access tokens in memory to reduce XSS risk.

Key concepts (short)

JWT (JSON Web Token)

A compact token containing user claims (e.g. id, roles, expiry). The server signs it. Do not trust client-side decoded tokens — server must verify.

Access Token vs Refresh Token

  • Access token: short-lived (minutes) used for API requests.
  • Refresh token: longer-lived (days/weeks), used to get new access tokens. Keep refresh tokens secure (HttpOnly cookie recommended).

Route Guards

Vue Router’s beforeEach guard is where you check authentication & role-based route meta (e.g. meta: { requiresAuth: true, roles: ['admin'] }).

Step-by-step implementation (frontend)

The following files show a complete minimal example you can adapt. I’m using Vue 3 + Vite + Pinia (for auth store) + axios. Replace the mock API with your real backend endpoints.

1) Axios API client with interceptor (src/services/api.js)

// src/services/api.js
import axios from 'axios'
import { useAuthStore } from '@/stores/auth' // Pinia store (created below)

const api = axios.create({
  baseURL: '/api', // adjust to your backend base
  withCredentials: true // allow cookies (if using HttpOnly cookies)
})

// Attach access token if available (from store)
api.interceptors.request.use(config => {
  const auth = useAuthStore()
  const token = auth.accessToken
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// Interceptor to handle 401 and attempt refresh
let isRefreshing = false
let queue = []

function processQueue(error, token = null) {
  queue.forEach(prom => {
    if (error) prom.reject(error)
    else prom.resolve(token)
  })
  queue = []
}

api.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config
    if (error.response && error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true
      const auth = useAuthStore()

      if (isRefreshing) {
        // queue the request until refresh completes
        return new Promise((resolve, reject) => {
          queue.push({ resolve, reject })
        }).then(token => {
          originalRequest.headers.Authorization = `Bearer ${token}`
          return axios(originalRequest)
        })
      }

      isRefreshing = true
      try {
        // call refresh endpoint. With HttpOnly cookie approach, this might not require payload.
        const resp = await axios.post('/api/auth/refresh', {}, { withCredentials: true })
        const newAccessToken = resp.data.accessToken
        auth.setAccessToken(newAccessToken)
        processQueue(null, newAccessToken)
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
        return axios(originalRequest)
      } catch (err) {
        processQueue(err, null)
        auth.logout() // clear store, redirect to login in store logic
        return Promise.reject(err)
      } finally {
        isRefreshing = false
      }
    }
    return Promise.reject(error)
  }
)

export default api

2) Auth store with Pinia (src/stores/auth.js)

// src/stores/auth.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/services/api'

export const useAuthStore = defineStore('auth', () => {
  // In-memory, avoids persisting access token in localStorage for security
  const accessToken = ref(null)
  const user = ref(null) // { id, name, roles: [] }
  const loading = ref(false)

  function setAccessToken(token) {
    accessToken.value = token
    // Optionally decode token client-side for claims (not a trust mechanism)
    // e.g. user.value = decode(token)
    // For real data fetch /api/me instead
  }

  async function login(credentials) {
    loading.value = true
    try {
      // Example: server returns { accessToken } and sets refresh token as HttpOnly cookie
      const res = await api.post('/auth/login', credentials, { withCredentials: true })
      setAccessToken(res.data.accessToken)
      // Fetch current user profile (server-validated)
      await loadUser()
      return true
    } finally {
      loading.value = false
    }
  }

  async function loadUser() {
    try {
      const r = await api.get('/auth/me') // server returns user object with roles
      user.value = r.data
    } catch (e) {
      user.value = null
    }
  }

  function logout() {
    // call backend to invalidate refresh token (recommended)
    try {
      api.post('/auth/logout', {}, { withCredentials: true })
    } catch (e) { /* ignore */ }
    accessToken.value = null
    user.value = null
    // Optionally navigate to login page here (use router)
  }

  const isAuthenticated = () => !!accessToken.value && !!user.value

  return { accessToken, user, loading, setAccessToken, login, loadUser, logout, isAuthenticated }
})

3) Router with guards (src/router/index.js)

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Login from '@/views/Login.vue'
import Dashboard from '@/views/Dashboard.vue'
import Admin from '@/views/Admin.vue'
import { useAuthStore } from '@/stores/auth'

const routes = [
  { path: '/', name: 'home', component: Home },
  { path: '/login', name: 'login', component: Login },
  { path: '/dashboard', name: 'dashboard', component: Dashboard, meta: { requiresAuth: true } },
  { path: '/admin', name: 'admin', component: Admin, meta: { requiresAuth: true, roles: ['admin'] } },
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach(async (to, from, next) => {
  const auth = useAuthStore()

  // If store hasn't loaded user but token exists, attempt loadUser
  if (!auth.user && auth.accessToken) {
    await auth.loadUser()
  }

  const requiresAuth = to.meta.requiresAuth
  const requiredRoles = to.meta.roles

  if (requiresAuth && !auth.isAuthenticated()) {
    return next({ name: 'login', query: { redirect: to.fullPath } })
  }

  if (requiredRoles && auth.user) {
    const hasRole = requiredRoles.some(r => (auth.user.roles || []).includes(r))
    if (!hasRole) return next({ name: 'home' }) // or show 403 page
  }

  return next()
})

export default router

<!-- src/views/Login.vue -->
<template>
  <div>
    <h2>Login</h2>
    <form @submit.prevent="onSubmit">
      <input v-model="email" placeholder="Email" />
      <input type="password" v-model="password" placeholder="Password" />
      <button type="submit">Login</button>
    </form>
    <p v-if="error">{{ error }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter, useRoute } from 'vue-router'

const email = ref('')
const password = ref('')
const error = ref(null)
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()

async function onSubmit() {
  error.value = null
  try {
    await auth.login({ email: email.value, password: password.value })
    // redirect to original page
    const redirect = route.query.redirect || '/dashboard'
    router.push(redirect)
  } catch (e) {
    error.value = 'Invalid credentials'
  }
}
</script>

5) Protected component with role checks (src/views/Admin.vue)

<!-- src/views/Admin.vue -->
<template>
  <div>
    <h2>Admin Panel</h2>
    <p>Only users with role "admin" may see this content.</p>
  </div>
</template>

6) Helper (optional) for template-level checks

// src/utils/roles.js
export function hasRole(user, role) {
  return user && Array.isArray(user.roles) && user.roles.includes(role)
}

Usage in template: <div v-if="hasRole(auth.user,'admin')">admin UI</div>

Server-side sketch (what your backend must do)

Your server must:

  • Authenticate credentials and issue a signed JWT access token and secure refresh token.
  • Store refresh tokens server-side (DB) or in HTTP-only cookies and support invalidation (logout).
  • Expose /auth/me to return current user claims based on validated access token.
  • Validate role-based permissions on every protected API endpoint (server must enforce RBAC).

Example JWT payload (server-side):

{
  "sub": "userId-123",
  "name": "Jane Doe",
  "roles": ["user","admin"],
  "iat": 1616142900,
  "exp": 1616146500
}

Important: Never trust client-side role checks alone — always validate permissions on the server.

Advanced: Refresh token flow & silent renew

Two common patterns:

  1. Access token in memory + refresh token in HttpOnly cookie — frontend requests /auth/refresh (no body required), server validates refresh cookie, responds with new access token. This avoids storing refresh token in JS and reduces XSS risk.
  2. Both tokens in cookies — server reads access token from cookie; less common if APIs are consumed by third parties.

The axios interceptor in api.js demonstrates retrying failed requests after refresh. Keep refresh endpoints rate-limited and validate refresh token rotation to prevent replay attacks.

UI/UX and developer tips

  • Show loading states for auth actions (login, refresh).
  • Save redirect URL on login redirect so you return users to their original page.
  • Provide a friendly “403 Forbidden” UI when role check fails, rather than silent redirects.
  • Log out users server-side on suspicious activity (invalidate refresh token).

Security best practices (don’t skip these)

  1. Use HTTPS for all auth traffic.
  2. Prefer HttpOnly, Secure cookies for refresh tokens. Avoid localStorage for refresh tokens.
  3. Keep access tokens short-lived and rotate refresh tokens.
  4. Implement CSRF protection if you use cookies for authentication (CSRF token or same-site cookie).
  5. Enforce server-side RBAC. Client-side role checks are for UX only.
  6. Rate-limit authentication endpoints to prevent credential stuffing.
  7. Monitor for token theft and provide ways to revoke sessions.

Deployment checklist

  • Set cookie flags: Secure; HttpOnly; SameSite=Strict/Lax where appropriate.
  • Set CORS carefully: allow only required origins and methods.
  • Keep secrets (JWT signing keys) safe and rotate them carefully.

Troubleshooting & common pitfalls

Probably because access token was only in memory and page reload clears it. Call /auth/refresh on app start to obtain a fresh access token if a refresh cookie exists.

“My guards are firing before loadUser completes”

Make your router guard wait for auth initialization — e.g., call auth.loadUser() in beforeEach if necessary or initialize auth in main.js before mounting app.

“Roles are in JWT but not showing in UI”

Either the token wasn’t decoded or loadUser endpoint wasn’t called. Prefer using a secure /auth/me API to get server-validated user object.

Full example summary — what you now have

With the code above you have:

  • Login flow and access token handling.
  • Pinia auth store for central auth state.
  • Axios client with refresh token handling & request queueing.
  • Router guards that enforce authentication and required roles per-route.
  • Template helpers and example admin-only view.

Next steps & production suggestions

  1. Implement backend token rotation and refresh token revocation.
  2. Add 2FA for higher security scenarios.
  3. Introduce session management UI (list active sessions & revoke).
  4. Audit your app for XSS and CSRF vulnerabilities.
  5. Write unit/integration tests for auth flows (mock tokens & API responses).
This website uses cookies to enhance your browsing experience. By continuing to use this site, you consent to the use of cookies. Please review our Privacy Policy for more information on how we handle your data. Cookie Policy