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.
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:
- Clients → Create client
- Client type:
OpenID Connect - Client ID:
vue-app - 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) orhttps://app.example.com(prod) - Valid redirect URIs:
http://localhost:5173/*andhttps://app.example.com/* - Valid post logout redirect URIs:
http://localhost:5173/andhttps://app.example.com/ - Web origins:
http://localhost:5173andhttps://app.example.com

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

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>

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
S256in 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.