SSO Integrationintermediate12 min readJune 12, 2026

Vue 3 SSO with Keycloak using OIDC + PKCE

Integrate Keycloak SSO into a Vue 3 single-page application using Authorization Code Flow with PKCE. Covers oidc-client-ts setup, a reusable auth composable, Vue Router guards, and token refresh.

KT

KeycloakPro Team

KeycloakPro Team

Introduction

Vue 3 SPAs run entirely in the browser, so they cannot safely store a client secret. The correct OIDC flow is Authorization Code Flow with PKCE (Proof Key for Code Exchange): no shared secret, with a cryptographic challenge proving the browser initiated the request.

This guide uses oidc-client-ts, the same library as the React PKCE guide, wrapped in a Vue 3 Composition API composable instead of a React context. The pattern works with any Vue 3 project (Vite, Nuxt in SPA mode).

Prerequisites

  • Keycloak 24+ with HTTPS
  • Vue 3 project (Vite recommended)
  • Node.js 20+
  • Keycloak admin access

Step 1 — Create the Keycloak public client

In the Keycloak admin console:

  1. Clients → Create client
  2. Client type: OpenID Connect
  3. Client ID: vue-app
  4. Click Next

Capability config:

  • Client authentication: OFF (public client — no secret is issued)
  • Standard flow: ON
  • Direct access grants: OFF
  • Click Next

Login settings:

  • Root URL: http://localhost:5173 (Vite dev) or https://app.example.com (prod)
  • Valid redirect URIs: http://localhost:5173/* and https://app.example.com/*
  • Valid post logout redirect URIs: http://localhost:5173/ and https://app.example.com/
  • Web origins: http://localhost:5173 and https://app.example.com
Keycloak admin console showing the vue-app client with Client authentication OFF, confirming it is a public client with no client secret
Public client — Client authentication OFF means no client secret is generated

1.1 — Enforce PKCE

Clients → vue-app → Advanced → Proof Key for Code Exchange Code Challenge Method → set to S256.

Keycloak admin console Advanced tab for vue-app showing Proof Key for Code Exchange Code Challenge Method set to S256
S256 enforcement — Keycloak rejects any authorization request that lacks a PKCE challenge

Step 2 — Install oidc-client-ts

npm install oidc-client-ts

Step 3 — Create the auth composable

Create src/composables/useAuth.ts:

import {
  UserManager,
  type User,
  type UserManagerSettings,
} from "oidc-client-ts";
import { ref, readonly, computed } from "vue";

const settings: UserManagerSettings = {
  authority: "https://keycloak.example.com/realms/YOUR_REALM",
  client_id: "vue-app",
  redirect_uri: window.location.origin + "/",
  post_logout_redirect_uri: window.location.origin + "/",
  scope: "openid email profile",
  automaticSilentRenew: true,
};

const userManager = new UserManager(settings);

const user = ref<User | null>(null);
const isLoading = ref(true);
const isAuthenticated = computed(() => !!user.value && !user.value.expired);

export async function initAuth(): Promise<void> {
  try {
    if (window.location.search.includes("code=")) {
      await userManager.signinRedirectCallback();
      // Remove the code from the URL so it isn't re-used on reload
      window.history.replaceState({}, "", "/");
    }
    user.value = await userManager.getUser();
  } finally {
    isLoading.value = false;
  }

  userManager.events.addUserLoaded((u) => {
    user.value = u;
  });
  userManager.events.addUserUnloaded(() => {
    user.value = null;
  });
}

export function useAuth() {
  return {
    user: readonly(user),
    isLoading: readonly(isLoading),
    isAuthenticated,
    signIn: () => userManager.signinRedirect(),
    signOut: () => userManager.signoutRedirect(),
    getAccessToken: (): string | null => user.value?.access_token ?? null,
  };
}

Replace YOUR_REALM with your realm name and keycloak.example.com with your Keycloak hostname.

user, isLoading, and isAuthenticated are module-level reactive state — all components that call useAuth() share the same instance. initAuth() should be called exactly once, from App.vue.

Step 4 — Initialize auth in App.vue

<!-- src/App.vue -->
<script setup lang="ts">
import { onMounted } from "vue";
import { initAuth, useAuth } from "@/composables/useAuth";
import { useRouter } from "vue-router";

const { isLoading, isAuthenticated, user, signIn, signOut } = useAuth();
const router = useRouter();

onMounted(async () => {
  await initAuth();
  const requiresAuth = router.currentRoute.value.meta.requiresAuth;
  if (requiresAuth && !isAuthenticated.value) {
    await signIn();
  }
});
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="!isAuthenticated">
    <h1>Welcome</h1>
    <button @click="signIn">Sign in with Keycloak</button>
  </div>
  <div v-else>
    <header>
      <span>{{ user?.profile.name }}</span>
      <button @click="signOut">Sign out</button>
    </header>
    <RouterView />
  </div>
</template>

Step 5 — Protect routes with Vue Router

// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import { useAuth } from "@/composables/useAuth";

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: "/",
      component: () => import("@/views/HomeView.vue"),
    },
    {
      path: "/dashboard",
      component: () => import("@/views/DashboardView.vue"),
      meta: { requiresAuth: true },
    },
  ],
});

router.beforeEach(async (to) => {
  if (!to.meta.requiresAuth) return true;

  const { isAuthenticated, signIn } = useAuth();
  if (!isAuthenticated.value) {
    await signIn();
    return false;
  }

  return true;
});

export default router;

Routes marked with meta: { requiresAuth: true } redirect unauthenticated visitors to Keycloak. The return false aborts the navigation because Keycloak's redirect takes over.

Step 6 — Use auth in a component

<!-- src/views/DashboardView.vue -->
<script setup lang="ts">
import { useAuth } from "@/composables/useAuth";

const { user, getAccessToken } = useAuth();

async function fetchProtectedData() {
  const token = getAccessToken();
  if (!token) return;

  const res = await fetch("https://api.example.com/data", {
    headers: { Authorization: `Bearer ${token}` },
  });
  return res.json();
}
</script>

<template>
  <div>
    <h1>Dashboard</h1>
    <p>Hello, {{ user?.profile.name }}</p>
    <p>Email: {{ user?.profile.email }}</p>
  </div>
</template>
Vue 3 app after successful Keycloak SSO login showing the user's name and email from Keycloak profile claims in the dashboard view
Post-login — profile claims from the Keycloak ID token displayed in the dashboard

Step 7 — Token refresh

oidc-client-ts handles token refresh automatically when automaticSilentRenew: true (set in Step 3). It renews the access token using the refresh token before expiry, and the addUserLoaded listener keeps the reactive user ref in sync.

Enable refresh token rotation in Keycloak to invalidate used refresh tokens: Realm Settings → Sessions → Refresh Token Rotation → ON.

Do not add a silent_redirect_uri to the settings. The iframe-based silent renew that setting enables is blocked by third-party cookie restrictions in modern browsers. Refresh token rotation is the correct renewal path.

Troubleshooting common issues

Redirect loop on the callback

The initAuth function checks window.location.search for code=. If the app hot-reloads with the code still in the URL, it tries to exchange an already-consumed code and fails. The window.history.replaceState call removes the code after exchange — confirm it runs before any navigation or component mount.

CORS preflight failures

Check Web origins in the Keycloak client. Add every origin your app uses during development (http://localhost:5173) and production. Do not use * in production.

user is null after login

getUser() returns null if the user object is absent or has expired. Confirm signinRedirectCallback() completed before getUser() is called. Check browser session storage — oidc-client-ts stores the user there by default under a key derived from the authority and client ID.

Logout doesn't clear the Keycloak session

signoutRedirect() calls Keycloak's end-session endpoint. If the session persists, check that post_logout_redirect_uri matches one of the Valid post logout redirect URIs on the Keycloak client.

Production checklist

  • HTTPS enforced — Keycloak rejects HTTP redirect URIs in production
  • PKCE Code Challenge Method set to S256 in Keycloak Advanced tab
  • automaticSilentRenew: true — access tokens renew before expiry
  • Refresh token rotation enabled in Keycloak realm settings
  • Web origins restricted to your production domain only
  • Direct access grants disabled on the Keycloak client
  • API validates tokens using the Keycloak JWKS endpoint

Need help integrating Vue with Keycloak?

We deliver production-ready Vue + Keycloak integrations in 1–3 weeks.

Fixed-price, zero vendor lock-in, full source code ownership.

See integration packages