Table of contents [Show]
- Why authentication & RBAC matter
- High-level architecture
- Key concepts (short)
- Step-by-step implementation (frontend)
- Server-side sketch (what your backend must do)
- Advanced: Refresh token flow & silent renew
- UI/UX and developer tips
- Security best practices (don’t skip these)
- Deployment checklist
- Troubleshooting & common pitfalls
- Full example summary — what you now have
- Next steps & production suggestions
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:
- Frontend collects credentials and calls authentication API.
- Server authenticates, returns an access token (short-lived) and a refresh token (longer-lived).
- Frontend stores access token (memory) or uses secure
HttpOnlycookie for tokens; refresh token kept inHttpOnlycookie. - Frontend includes access token in API calls (Authorization header) or server reads cookie.
- When access token expires, frontend requests a new one using the refresh token.
- 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
4) Login component (src/views/Login.vue)
<!-- 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/meto 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:
- 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.
- 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)
- Use HTTPS for all auth traffic.
- Prefer HttpOnly, Secure cookies for refresh tokens. Avoid localStorage for refresh tokens.
- Keep access tokens short-lived and rotate refresh tokens.
- Implement CSRF protection if you use cookies for authentication (CSRF token or same-site cookie).
- Enforce server-side RBAC. Client-side role checks are for UX only.
- Rate-limit authentication endpoints to prevent credential stuffing.
- Monitor for token theft and provide ways to revoke sessions.
Deployment checklist
- Set cookie flags:
Secure; HttpOnly; SameSite=Strict/Laxwhere appropriate. - Set CORS carefully: allow only required origins and methods.
- Keep secrets (JWT signing keys) safe and rotate them carefully.
Troubleshooting & common pitfalls
“Why am I redirected to login on refresh?”
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.






