Add Keycloak Authentication to Your Vue.js Application
Last updated: March 2026
Vue.js has become one of the most popular frameworks for building single-page applications, but adding authentication to a Vue app means making a choice: roll your own auth layer (risky and time-consuming), use a proprietary service (vendor lock-in), or integrate with an open-source identity provider that gives you full control over your user data.
Keycloak is that third option. It handles single sign-on, multi-factor authentication, social logins, and role-based access control out of the box, and it works well with Vue.js through the official keycloak-js JavaScript adapter.
This guide walks through a complete integration of Keycloak with Vue 3, using the Composition API, Pinia for auth state management, vue-router for route protection, and automatic token refresh. By the end, you will have a working authentication system with login, logout, protected routes, and role-based UI rendering.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ and npm 9+ installed
- Vue CLI or create-vue (
npm create vue@latest) - A running Keycloak instance (version 22 or later). You can spin one up locally with the Skycloak Docker Compose Generator, or use a managed instance from Skycloak.
- Basic familiarity with Vue 3 and TypeScript
For a quick local Keycloak instance:
docker run -p 8080:8080
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin
quay.io/keycloak/keycloak:26.0 start-dev
This starts Keycloak at http://localhost:8080 with admin credentials admin/admin.
Setting Up the Keycloak Client
Before writing any Vue code, configure a client in the Keycloak admin console.
Create a Realm
- Log into the admin console at
http://localhost:8080/admin - Click the realm dropdown and select Create realm
- Name it
vue-appand click Create
Create a Client
- Go to Clients and click Create client
- Set:
- Client type: OpenID Connect
- Client ID:
vue-frontend
- On Capability Config:
- Client authentication: Off (public client, required for SPAs)
- Standard flow: Enabled
- Direct access grants: Disabled
- On Login Settings:
- Valid redirect URIs:
http://localhost:5173/* - Valid post logout redirect URIs:
http://localhost:5173/* - Web origins:
http://localhost:5173
- Valid redirect URIs:
Create Roles
- Go to Realm roles and create two roles:
userandadmin - Go to Users, create a test user, set a password under the Credentials tab, and assign the
userrole under Role mappings
Scaffolding the Vue Project
Create a new Vue 3 project with TypeScript, Pinia, and vue-router:
npm create vue@latest vue-keycloak-app --
--typescript --router --pinia
cd vue-keycloak-app
npm install
Install the Keycloak JavaScript adapter:
npm install keycloak-js
Your package.json dependencies should look like this:
{
"dependencies": {
"keycloak-js": "^26.0.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "~5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
}
}
Initializing the Keycloak Adapter
Create a Keycloak configuration file that initializes the adapter and exports it for use across the application.
Create src/keycloak.ts:
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080',
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'vue-app',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'vue-frontend',
});
export default keycloak;
Add environment variables to .env:
VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=vue-app
VITE_KEYCLOAK_CLIENT_ID=vue-frontend
Building the Auth Store with Pinia
Pinia provides reactive state management that works naturally with the Composition API. The auth store manages the user’s authentication state, profile information, and roles.
Create src/stores/auth.ts:
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js';
import keycloak from '@/keycloak';
export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = ref(false);
const user = ref<KeycloakProfile | null>(null);
const token = ref<string | undefined>(undefined);
const tokenParsed = ref<KeycloakTokenParsed | undefined>(undefined);
const roles = ref<string[]>([]);
const isLoading = ref(true);
const userName = computed(() => {
if (user.value) {
return `${user.value.firstName} ${user.value.lastName}`;
}
return tokenParsed.value?.preferred_username || 'Unknown';
});
const hasRole = (role: string): boolean => {
return roles.value.includes(role);
};
const isAdmin = computed(() => hasRole('admin'));
async function init(): Promise<boolean> {
try {
const authenticated = await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri:
window.location.origin + '/silent-check-sso.html',
checkLoginIframe: false,
});
isAuthenticated.value = authenticated;
if (authenticated) {
token.value = keycloak.token;
tokenParsed.value = keycloak.tokenParsed;
roles.value = keycloak.realmAccess?.roles || [];
try {
const profile = await keycloak.loadUserProfile();
user.value = profile;
} catch (err) {
console.warn('Could not load user profile:', err);
}
startTokenRefresh();
}
isLoading.value = false;
return authenticated;
} catch (error) {
console.error('Keycloak init failed:', error);
isLoading.value = false;
return false;
}
}
function login(redirectUri?: string): void {
keycloak.login({
redirectUri: redirectUri || window.location.origin,
});
}
function logout(): void {
keycloak.logout({
redirectUri: window.location.origin,
});
}
function startTokenRefresh(): void {
setInterval(async () => {
try {
const refreshed = await keycloak.updateToken(60);
if (refreshed) {
token.value = keycloak.token;
tokenParsed.value = keycloak.tokenParsed;
}
} catch {
console.warn('Token refresh failed, logging out');
logout();
}
}, 30000);
}
return {
isAuthenticated,
user,
token,
tokenParsed,
roles,
isLoading,
userName,
isAdmin,
hasRole,
init,
login,
logout,
};
});
The store uses check-sso initialization, which checks if the user has an existing session without forcing a login redirect. The startTokenRefresh function runs every 30 seconds and refreshes the token if it expires within the next 60 seconds.
Creating the useAuth Composable
While the Pinia store handles state, a composable provides a cleaner API for components and adds utility functions.
Create src/composables/useAuth.ts:
import { useAuthStore } from '@/stores/auth';
import { storeToRefs } from 'pinia';
import keycloak from '@/keycloak';
export function useAuth() {
const authStore = useAuthStore();
const {
isAuthenticated,
user,
token,
roles,
isLoading,
userName,
isAdmin,
} = storeToRefs(authStore);
function getToken(): string | undefined {
return keycloak.token;
}
async function getValidToken(): Promise<string | undefined> {
try {
await keycloak.updateToken(30);
return keycloak.token;
} catch {
authStore.logout();
return undefined;
}
}
async function authenticatedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const validToken = await getValidToken();
if (!validToken) {
throw new Error('Not authenticated');
}
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${validToken}`,
'Content-Type': 'application/json',
},
});
}
return {
isAuthenticated,
user,
token,
roles,
isLoading,
userName,
isAdmin,
hasRole: authStore.hasRole,
login: authStore.login,
logout: authStore.logout,
getToken,
getValidToken,
authenticatedFetch,
};
}
The authenticatedFetch function is particularly useful. It automatically refreshes the token before making API calls and attaches the Authorization header. You can use the JWT Token Analyzer to inspect the tokens Keycloak issues and verify that your claims and roles are correctly included.
Setting Up the Router with Guards
Vue Router’s navigation guards let you protect routes based on authentication state and roles.
Update src/router/index.ts:
import {
createRouter,
createWebHistory,
type RouteLocationNormalized,
} from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import HomeView from '@/views/HomeView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
meta: { requiresAuth: true },
},
{
path: '/unauthorized',
name: 'unauthorized',
component: () => import('@/views/UnauthorizedView.vue'),
},
],
});
router.beforeEach(
async (to: RouteLocationNormalized, _from: RouteLocationNormalized) => {
const authStore = useAuthStore();
// Wait for Keycloak initialization
if (authStore.isLoading) {
await new Promise<void>((resolve) => {
const unwatch = authStore.$subscribe(() => {
if (!authStore.isLoading) {
unwatch();
resolve();
}
});
});
}
const requiresAuth = to.meta.requiresAuth as boolean | undefined;
const requiredRoles = to.meta.roles as string[] | undefined;
if (requiresAuth && !authStore.isAuthenticated) {
authStore.login(window.location.origin + to.fullPath);
return false;
}
if (requiredRoles && requiredRoles.length > 0) {
const hasRequiredRole = requiredRoles.some((role) =>
authStore.hasRole(role)
);
if (!hasRequiredRole) {
return { name: 'unauthorized' };
}
}
return true;
}
);
export default router;
The guard checks two things: whether the user is authenticated (redirecting to Keycloak login if not) and whether the user has the required roles. This is client-side enforcement only. Always validate roles server-side as well. See our guide on RBAC in Keycloak for more on role-based access patterns.
Initializing Keycloak in main.ts
Update src/main.ts to initialize Keycloak before mounting the app:
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { useAuthStore } from './stores/auth';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
const authStore = useAuthStore();
authStore.init().then(() => {
app.mount('#app');
});
Initializing Keycloak before app.mount() prevents the application from rendering before the authentication state is known. This avoids the flash of unauthenticated content that can happen if you initialize asynchronously after mount.
Creating the Silent SSO Check
For the check-sso flow to work without a full redirect, create a static HTML file that Keycloak loads in a hidden iframe.
Create public/silent-check-sso.html:
<!DOCTYPE html>
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
This file allows Keycloak to silently check for an existing session when the user returns to the app, maintaining SSO across tabs and page reloads without forcing a login redirect.
Building the UI Components
Navigation Bar
Create src/components/NavBar.vue:
<script setup lang="ts">
import { useAuth } from '@/composables/useAuth';
import { RouterLink } from 'vue-router';
const { isAuthenticated, userName, isAdmin, login, logout } = useAuth();
</script>
<template>
<nav class="navbar">
<div class="nav-brand">
<RouterLink to="/">Vue Keycloak App</RouterLink>
</div>
<div class="nav-links">
<RouterLink to="/">Home</RouterLink>
<template v-if="isAuthenticated">
<RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/profile">Profile</RouterLink>
<RouterLink v-if="isAdmin" to="/admin">Admin</RouterLink>
</template>
</div>
<div class="nav-auth">
<template v-if="isAuthenticated">
<span class="user-name">{{ userName }}</span>
<button @click="logout" class="btn-logout">Logout</button>
</template>
<template v-else>
<button @click="login()" class="btn-login">Login</button>
</template>
</div>
</nav>
</template>
Dashboard with API Calls
Create src/views/DashboardView.vue:
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAuth } from '@/composables/useAuth';
const { userName, roles, authenticatedFetch } = useAuth();
const apiData = ref<string>('');
const error = ref<string>('');
async function fetchProtectedData() {
try {
const response = await authenticatedFetch('/api/protected-resource');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
apiData.value = JSON.stringify(data, null, 2);
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch data';
}
}
onMounted(() => {
fetchProtectedData();
});
</script>
<template>
<div class="dashboard">
<h1>Dashboard</h1>
<p>Welcome, {{ userName }}</p>
<div class="roles">
<h3>Your Roles</h3>
<ul>
<li v-for="role in roles" :key="role">{{ role }}</li>
</ul>
</div>
<div v-if="apiData" class="api-data">
<h3>API Response</h3>
<pre>{{ apiData }}</pre>
</div>
<div v-if="error" class="error">
<p>{{ error }}</p>
</div>
</div>
</template>
Profile View
Create src/views/ProfileView.vue:
<script setup lang="ts">
import { useAuth } from '@/composables/useAuth';
const { user, tokenParsed, roles } = useAuth();
</script>
<template>
<div class="profile">
<h1>Profile</h1>
<div v-if="user" class="profile-card">
<div class="profile-field">
<label>Username</label>
<span>{{ tokenParsed?.preferred_username }}</span>
</div>
<div class="profile-field">
<label>Email</label>
<span>{{ user.email }}</span>
</div>
<div class="profile-field">
<label>First Name</label>
<span>{{ user.firstName }}</span>
</div>
<div class="profile-field">
<label>Last Name</label>
<span>{{ user.lastName }}</span>
</div>
<div class="profile-field">
<label>Roles</label>
<span>{{ roles.join(', ') }}</span>
</div>
</div>
</div>
</template>
Handling Token Refresh and Expiration
The Pinia store already includes a basic token refresh interval, but you should also handle edge cases like browser tab switching and network interruptions.
Add these event listeners to your src/keycloak.ts:
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080',
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'vue-app',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'vue-frontend',
});
// Refresh token when tab regains focus
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && keycloak.authenticated) {
try {
await keycloak.updateToken(60);
} catch {
console.warn('Token refresh on focus failed');
}
}
});
// Handle token expiration events
keycloak.onTokenExpired = () => {
keycloak.updateToken(60).catch(() => {
console.warn('Token expired and refresh failed');
keycloak.logout();
});
};
export default keycloak;
The visibilitychange listener ensures the token is refreshed when the user switches back to the tab after being away. The onTokenExpired callback is a safety net that catches cases the interval might miss.
Protecting API Requests
If you use Axios instead of fetch, you can set up a request interceptor that automatically attaches the access token.
npm install axios
Create src/api/http.ts:
import axios from 'axios';
import keycloak from '@/keycloak';
const http = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
headers: {
'Content-Type': 'application/json',
},
});
http.interceptors.request.use(
async (config) => {
if (keycloak.authenticated) {
try {
await keycloak.updateToken(30);
config.headers.Authorization = `Bearer ${keycloak.token}`;
} catch {
keycloak.logout();
}
}
return config;
},
(error) => Promise.reject(error)
);
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
keycloak.logout();
}
return Promise.reject(error);
}
);
export default http;
The request interceptor refreshes the token before every API call, so you never send an expired token. The response interceptor catches 401 responses and forces a re-login.
Adding Role-Based UI Rendering
Create a directive that conditionally renders elements based on roles:
Create src/directives/hasRole.ts:
import type { Directive, DirectiveBinding } from 'vue';
import keycloak from '@/keycloak';
export const vHasRole: Directive<HTMLElement, string | string[]> = {
mounted(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
const requiredRoles = Array.isArray(binding.value)
? binding.value
: [binding.value];
const userRoles = keycloak.realmAccess?.roles || [];
const hasRole = requiredRoles.some((role) => userRoles.includes(role));
if (!hasRole) {
el.style.display = 'none';
}
},
};
Register it in main.ts:
import { vHasRole } from './directives/hasRole';
// ... after creating app
app.directive('has-role', vHasRole);
Use it in templates:
<template>
<button v-has-role="'admin'" @click="deleteUser">Delete User</button>
<button v-has-role="['admin', 'manager']" @click="editSettings">
Settings
</button>
</template>
Remember that client-side role checks are for UX only. The actual access control must happen on your backend. For a deeper understanding of role hierarchies and composite roles, see the Keycloak Server Administration Guide.
Debugging Common Issues
CORS Errors
If you see CORS errors during development, verify that your Web origins in the Keycloak client settings matches your Vue dev server URL exactly (http://localhost:5173). Do not use wildcards in production. For more on CORS with Keycloak, see our post on configuring CORS with Keycloak OIDC clients.
Token Issues
Use the JWT Token Analyzer to decode access tokens and check:
- The
audclaim matches your client ID - The
realm_access.rolesarray contains expected roles - The
exptimestamp has not passed - The
issURL matches your Keycloak server URL
Redirect Loop
If you experience an infinite redirect loop after login, check that your redirect URI in Keycloak matches exactly what Vue sends (including trailing slashes). Also verify the silentCheckSsoRedirectUri path is correct and the HTML file exists. See our login loop troubleshooting guide for detailed solutions.
Session Management
Keycloak provides built-in session management capabilities. You can configure session timeouts, idle timeouts, and maximum session lengths in the Keycloak admin console under Realm Settings > Sessions. The keycloak-js adapter respects these server-side settings automatically.
Production Considerations
Before deploying to production, make several adjustments:
-
Use HTTPS: Keycloak requires HTTPS in production. Set
ssl-requiredtoallin your realm settings. -
Configure proper redirect URIs: Replace
localhostURIs with your production domain. Be as specific as possible rather than using wildcards. -
Set token lifespans appropriately: Access tokens should be short-lived (5-15 minutes). Refresh tokens can be longer but should have an absolute timeout.
-
Enable PKCE: While public clients already use PKCE by default in recent Keycloak versions, verify it is enabled in your client settings.
-
Monitor with audit logs: Enable audit logging to track login events, failed authentications, and administrative changes.
-
Consider a managed deployment: Running Keycloak in production requires handling updates, high availability, backups, and security patches. Skycloak handles all of this, letting you focus on your Vue application instead of infrastructure. Check our SLA for uptime guarantees.
Next Steps
With Keycloak integrated into your Vue application, you have a solid authentication foundation. From here, you can:
- Add identity provider federation to allow users to log in with Google, GitHub, or enterprise SAML providers
- Implement SCIM provisioning for automated user management across systems
- Use the Keycloak Config Generator to quickly set up realm configurations
- Customize the login page with your brand theming
- Set up the SAML Decoder if you need to debug SAML-based identity provider connections
Try Skycloak
If you want to skip the infrastructure overhead and focus on building your Vue application, Skycloak provides fully managed Keycloak instances with automatic updates, backups, and high availability. See our pricing to find the plan that fits your needs.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.