Add Keycloak Authentication to Your Vue.js Application

Guilliano Molaire Guilliano Molaire 12 min read

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

  1. Log into the admin console at http://localhost:8080/admin
  2. Click the realm dropdown and select Create realm
  3. Name it vue-app and click Create

Create a Client

  1. Go to Clients and click Create client
  2. Set:
    • Client type: OpenID Connect
    • Client ID: vue-frontend
  3. On Capability Config:
    • Client authentication: Off (public client, required for SPAs)
    • Standard flow: Enabled
    • Direct access grants: Disabled
  4. On Login Settings:
    • Valid redirect URIs: http://localhost:5173/*
    • Valid post logout redirect URIs: http://localhost:5173/*
    • Web origins: http://localhost:5173

Create Roles

  1. Go to Realm roles and create two roles: user and admin
  2. Go to Users, create a test user, set a password under the Credentials tab, and assign the user role 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 aud claim matches your client ID
  • The realm_access.roles array contains expected roles
  • The exp timestamp has not passed
  • The iss URL 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:

  1. Use HTTPS: Keycloak requires HTTPS in production. Set ssl-required to all in your realm settings.

  2. Configure proper redirect URIs: Replace localhost URIs with your production domain. Be as specific as possible rather than using wildcards.

  3. Set token lifespans appropriately: Access tokens should be short-lived (5-15 minutes). Refresh tokens can be longer but should have an absolute timeout.

  4. Enable PKCE: While public clients already use PKCE by default in recent Keycloak versions, verify it is enabled in your client settings.

  5. Monitor with audit logs: Enable audit logging to track login events, failed authentications, and administrative changes.

  6. 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:

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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman