Permission-Based Access Control in Vue Using JWT, Directives & Route Guards
In modern web applications, controlling what each user can see and do is crucial for both security and usability. Whether you’re building a SaaS dashboard or an internal tool, role-based and permission-based access control is a must-have feature.
In this blog, I’ll walk you through how I implemented permission-based access control in a Vue 3 application using:
- JWT tokens from the backend
- Custom directives for disabling UI
- Route guards for blocking unauthorized navigation
This setup provides robust control without cluttering your components.
The Goal
We want to:
✅ Decode user permissions from a JWT token
✅ Enable/disable UI actions based on those permissions
✅ Show tooltips when features are disabled
✅ Hide restricted features entirely
✅ Block unauthorised routes via router guards
Permissions Design
The backend sends a JWT token with a permissions object embedded:
{
"permissions": {
"dashboardAnalytics": "full",
"projectSettings": "readonly",
"userManagement": "denied"
}
}
Each permission can be:
full– full accessreadonly– can view, not modifydenied– cannot access at all
1. Decoding JWT & Managing Permissions
Store the permissions in a reactive ref:
// @utils/permission.ts
import { ref } from 'vue'
export const permissionList = ref<Record<string, string>>({})
export const getAllPermissions = (() => {
const apiToken = document.cookie
.split('; ')
.find(row => row.startsWith('api_token='))
?.split('=')[1]
if (apiToken) {
const [, payload] = apiToken.split('.')
if (payload) {
const decodedPayload = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
)
permissionList.value = decodedPayload.permissions || {}
}
}
})()
export const hasPermission = (permission: string): boolean => {
const permValue = permissionList.value?.[permission]
if (permValue === undefined) return true
return permValue === 'full'
}
export const hasPermissionDenied = (permission: string): boolean => {
return permissionList.value?.[permission] === 'denied'
}
The
_getAllPermissions()_function should be called during app initialization (e.g., in_main.ts_or after login).
2. Vue Directive: v-permission
We created a custom Vue directive to apply permission logic declaratively to components.
// @directives/permission.ts
import { hasPermission } from '@utils/permission'
export default {
mounted(el, binding) {
const permission = typeof binding.value === 'string' ? binding.value : ''
const tooltipMessage = 'You do not have permission to access this feature.'
if (permission && !hasPermission(permission)) {
el.setAttribute('disabled', 'true')
el.classList.add('tw-cursor-not-allowed', '!tw-opacity-50')
el.style.cursor = 'not-allowed'
let tooltipEl: HTMLElement | null = null
const showTooltip = () => {
tooltipEl = document.createElement('div')
tooltipEl.classList.add('custom-tooltip')
tooltipEl.textContent = tooltipMessage
document.body.appendChild(tooltipEl)
const rect = el.getBoundingClientRect()
const tooltipHeight = tooltipEl.offsetHeight
const spaceAbove = rect.top
tooltipEl.style.top = spaceAbove >= tooltipHeight + 6
? `${rect.top - tooltipHeight - 6 + window.scrollY}px`
: `${rect.bottom + 6 + window.scrollY}px`
tooltipEl.style.left = `${rect.left + rect.width / 2 - tooltipEl.offsetWidth / 2 + window.scrollX}px`
}
const hideTooltip = () => {
if (tooltipEl) {
tooltipEl.remove()
tooltipEl = null
}
}
el.addEventListener('mouseenter', showTooltip)
el.addEventListener('mouseleave', hideTooltip)
el.addEventListener('focus', showTooltip)
el.addEventListener('blur', hideTooltip)
const blocker = (e: Event) => {
e.preventDefault()
e.stopImmediatePropagation()
}
el.addEventListener('click', blocker, true)
el.addEventListener('mousedown', blocker, true)
el.addEventListener('keydown', blocker, true)
}
},
}
Usage in Components
Here’s how you’d use it in your template:
<Button
v-permission="'dashboardAnalytics'"
@click="addWidget"
label="Add Widget"
class="btn"
/>
If the user lacks permission, the button will be:
- Disabled
- Semi-transparent
- Unclickable
- Showing a tooltip
3. Conditionally Hiding UI with v-if
For fully denied permissions, you can use:
<Button
v-if="!hasPermissionDenied('userManagement')"
@click="openUserManager"
label="Manage Users"
/>
This hides the button completely if access is explicitly denied.
4. Securing Routes with Router Guards
To prevent users from manually navigating to protected routes, we add permission metadata and guard logic.
Sample usage:
{
path: '/:orgId/:projectId/dashboard-analytics',
name: 'dashboard-analytics',
meta: {
layout: 'default',
permission: 'dashboardAnalytics',
},
component: () => import('@pages/analytics/index.vue'),
}
🔐 Global Navigation Guard
// router.ts
import { hasPermissionDenied } from '@utils/permission'
router.beforeEach((to, from, next) => {
const permissionKey = to?.meta?.permission
const permissionDenied = permissionKey && hasPermissionDenied(permissionKey as string)
if (permissionDenied) {
if (to.name !== 'access-denied') {
return next({ name: 'access-denied' })
}
return next()
}
NProgress.start()
next()
})
🚫 Access Denied Route
{
path: '/access-denied',
name: 'access-denied',
component: () => import('@pages/common/AccessDenied.vue'),
meta: {
layout: 'minimal',
},
}
With this setup:
- Unauthorized users can’t see or interact with restricted UI
- Routes are blocked at the navigation layer
- Users get clear feedback via tooltips or redirection
- Templates stay clean and declarative
This approach strikes a great balance between security, usability, and maintainability. You can easily extend it further to support:
- Role-based logic
- Multi-tenant permissions
- Asynchronous permission loading
Action buttons looks like:
I’d love to hear how you’re handling permissions in your apps! Feel free to leave a comment or share your ideas.
#VueJS #FrontendDevelopment #WebSecurity #JWT #AccessControl #Vue3 #RoleBasedAccess