Securing Nuxt Apps with Keycloak
In this article we'll be using Keycloak to quickly secure a Nuxt application with user management and single sign on (SSO) using the open source IAMs Keycloak for Authentication and Authorization. We will demonstrate the integration by securing a page for logged-in users. This quickly provides a jump-off point to more complex integrations.
If you just want to skip to the code, visit the Phase Two Nuxt example.
Setting up a Keycloak Instance
Instructions
If you already have a Keycloak instance, skip to the next section.
You can run Keycloak on your machine with Docker, or use a hosted Phase Two cluster. Both come with Phase Two's Keycloak extensions.
Run Keycloak locally
The Phase Two examples repo includes a local Phase Two Keycloak that is already set up for the examples: a p2examples realm with a client for each example and a demo user. You need Docker with the Compose plugin.
git clone https://github.com/p2-inc/examples.git
cd examples
docker compose -f keycloak/docker-compose.yml up -d --wait
| What | Value |
|---|---|
| Issuer URL | http://localhost:8080/auth/realms/p2examples |
| Admin console | localhost:8080/auth/admin, admin / admin |
| Demo user | demo / demo |
The client and the user from the next two sections already exist in this realm, so you can skip both. The local Keycloak README lists the client of each example. docker compose -f keycloak/docker-compose.yml down stops Keycloak, and the next up starts again from a fresh realm.
Use a hosted Phase Two cluster
- Sign up on the Phase Two Dashboard with an email address, a GitHub account or a Google account.
- Create a Starter cluster, which is free for 30 days, and add a realm to it.
- Click Open Console to open the realm in the Keycloak admin console.
The app connects to Keycloak through the realm's issuer URL, https://<your-keycloak-host>/auth/realms/<your-realm>. In the admin console, Realm settings > General > Endpoints > OpenID Endpoint Configuration shows it as issuer.
Keep the admin console open: the next sections create the app's client and a user there.
Setting up an OIDC Client
Instructions
The app needs an OpenID Connect client in Keycloak. The app runs in the browser, where it can't keep a secret, so it gets a public client and logs users in with the authorization code flow and PKCE. Keycloak's docs describe all the client settings.
-
Open the Keycloak admin console and select your realm.
-
Click Clients in the menu.
-
Click Create client.
-
Leave Client type set to OpenID Connect.
-
Enter a Client ID. The app sends this ID in its OpenID Connect requests.
-
Supply a Name for the client.
-
Click Next.

-
Under Capability config:
- Leave Client authentication off.
- Leave Standard flow checked and Direct access grants unchecked. The app logs users in through Keycloak's login page, so it never needs their password.
- Turn Require PKCE on, and leave PKCE Method set to S256.
-
Click Next.

-
Under Login settings, enter the app's URLs. For an app running on
http://localhost:3000:Valid redirect URIs (where Keycloak may send users back after they log in)
http://localhost:3000/*Valid post logout redirect URIs (where Keycloak may send users back after they log out;
+means the valid redirect URIs)+Web origins (the origins allowed to call Keycloak from the browser, for example to get tokens;
+means the origins of the valid redirect URIs)+URI and Origin Details
Use the port of the app you run: most examples run on port 3000, and the Angular examples on 4200. For an app deployed somewhere, use its URL instead of
localhost. -
Click Save.

OIDC Config
The app needs two values from Keycloak:
- Issuer URL: the realm's URL, such as
http://localhost:8080/auth/realms/p2examplesfor the local Keycloak, orhttps://<your-keycloak-host>/auth/realms/<your-realm>. Realm settings > General > Endpoints > OpenID Endpoint Configuration shows it asissuer. - Client ID: the ID you entered when you created the client.
Adding a Non-Admin User
Instructions
It is bad practice to use your admin user to sign in to an application.
The local Keycloak from the examples repo already has a non-admin user, demo / demo. On any other Keycloak, add one:
- Open the Keycloak admin console and select your realm.
- Click Users in the menu.
- Click Add user.
- Fill out the Username, Email, First name and Last name. Click Create.
- Open the Credentials tab and click Set password. Enter a password for the user. For this tutorial, you can turn Temporary off.
- Click Save, then confirm with Save password.
Setting up a Nuxt Project
We will use the Phase Two Nuxt example code here, but the logic could easily be applied to any existing application.
This example uses Nuxt 4. There are a couple methods by which you can integrate Keycloak to your Nuxt application. We're going to explore two methods here, one uses keycloak-js and the other leverages oidc-client-ts. The keycloak-js library provides a simple, client-only method, but lacks some of the sophistication provided by the oidc-client-ts library that is heavily supported and more widely used.
Both examples render in the browser (ssr: false) and log users in with the authorization code flow and PKCE, so they use a public client without a client secret. Both run on port 3000, so one client works for both. You need Node.js 24 and pnpm.
Using keycloak-js
-
Clone the Phase Two example repo.
-
Open the Nuxt folder within
/frameworks/nuxtand open thekeycloak-jsfolder within/frameworks/nuxt/keycloak-js. -
Point the app at your Keycloak. The defaults in
nuxt.config.tspoint at the hosted Phase Two demo realm:runtimeConfig: {public: {keycloakUrl: "https://app.phasetwo.io/auth",keycloakRealm: "p2examples",keycloakClientId: "nuxt-example",},},Nuxt overrides them with the matching environment variables:
NUXT_PUBLIC_KEYCLOAK_URL,NUXT_PUBLIC_KEYCLOAK_REALMandNUXT_PUBLIC_KEYCLOAK_CLIENT_ID. To use the local Keycloak from the examples repo, copy the example file:cp .env.example .env.envpoints the app at the local Keycloak and itsnuxt-exampleclient:NUXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080/authNUXT_PUBLIC_KEYCLOAK_REALM=p2examplesNUXT_PUBLIC_KEYCLOAK_CLIENT_ID=nuxt-exampleFor another Keycloak, create
.envthe same way. keycloak-js takes the Keycloak URL and the realm separately, so split your realm's issuer URL,https://<your-keycloak-host>/auth/realms/<your-realm>: setNUXT_PUBLIC_KEYCLOAK_URLtohttps://<your-keycloak-host>/authandNUXT_PUBLIC_KEYCLOAK_REALMto<your-realm>. SetNUXT_PUBLIC_KEYCLOAK_CLIENT_IDto the ID of your client. -
Run
pnpm installand thenpnpm dev, and open localhost:3000.keycloak-jsis a JavaScript library that provides a fast way to secure an application. -
The project makes use of the following Nuxt items, all in the
app/folder: components, composables, layouts, pages and plugins. We'll review the ones that handle the login. -
The main component that shows the user's authenticated state is in
app/components/UserStatus.vue. In this component we call theuseKeycloakcomposable, which returns the login state and theloginandlogoutfunctions that we've wrapped to make easily available.const { state, login, logout } = useKeycloak();Lower in the file the component leverages
v-ifchecks onstate.authenticatedandstate.error. Depending on the state, it shows the user's name and email with a Log out button, an error, or a Log in button. TheTokenPanelscomponent shows the decoded tokens. -
Let's take a look at the setup for the composable next. Our composable is in
app/composables/useKeycloak.ts. A composable is a function defined that can be called anywhere in the Nuxt application. It's a good way to abstract logic to be reused. In our caseuseKeycloakState()holds the authenticated state withuseState, anduseKeycloak()wraps thekeycloak-jsadapter that the plugin provides (more on that in the next step).import type { KeycloakTokenParsed } from "keycloak-js";type KeycloakState = {authenticated: boolean;tokenParsed?: KeycloakTokenParsed;idTokenParsed?: KeycloakTokenParsed;error?: string;};export const useKeycloakState = () =>useState<KeycloakState>("keycloak", () => ({ authenticated: false }));export function useKeycloak() {const { $keycloak } = useNuxtApp();return {state: useKeycloakState(),login: () => $keycloak.login(),logout: () =>$keycloak.logout({ redirectUri: `${window.location.origin}/` }),};}logoutends the Keycloak session and sends the user back to the app. -
In the plugin,
app/plugins/keycloak.client.ts, we instantiate thekeycloak-jsadapter with the values from the runtime config. We then provide that instance to the app as$keycloak.import Keycloak from "keycloak-js";export default defineNuxtPlugin(async () => {const { keycloakUrl, keycloakRealm, keycloakClientId } =useRuntimeConfig().public;const keycloak = new Keycloak({url: keycloakUrl,realm: keycloakRealm,clientId: keycloakClientId,});const state = useKeycloakState();const sync = () => {state.value = {...state.value,authenticated: keycloak.authenticated === true,tokenParsed: keycloak.tokenParsed,idTokenParsed: keycloak.idTokenParsed,};};keycloak.onAuthSuccess = sync;keycloak.onAuthRefreshSuccess = sync;keycloak.onAuthLogout = sync;keycloak.onTokenExpired = () => {keycloak.updateToken(30).catch(() => {keycloak.clearToken();sync();});};try {await keycloak.init({onLoad: "check-sso",silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,pkceMethod: "S256",checkLoginIframe: false,});} catch (error) {state.value = {...state.value,error:error instanceof Error? error.message: "Could not initialize keycloak-js",};}sync();return { provide: { keycloak } };});Nuxt waits for the plugin before it renders the app, so the logged-out UI doesn't flash while keycloak-js starts. With
check-sso, keycloak-js logs the user in only if they already have a Keycloak session. It checks in a hidden iframe that loadspublic/silent-check-sso.html, so the page doesn't reload:<!doctype html><html><body><script>parent.postMessage(location.href, location.origin);</script></body></html>When the access token expires, the plugin refreshes it with
updateToken. The event handlers copy the adapter's state intouseKeycloakState(), so the components update. -
The logic for checking the
authenticatedstate can be used to expand in ways to secure your site in a number of ways.
Using oidc-client-ts
The oidc-client-ts package is a well-maintained and used library. It provides a lot of utilities for building out a fully production app.
-
Clone the Phase Two example repo.
-
Open the Nuxt folder within
/frameworks/nuxtand open theoidc-client-tsfolder within/frameworks/nuxt/oidc-client-ts. -
Point the app at your Keycloak. The defaults in
nuxt.config.tspoint at the hosted Phase Two demo realm:runtimeConfig: {public: {oidcIssuerUri: "https://app.phasetwo.io/auth/realms/p2examples",oidcClientId: "nuxt-oidc-client-ts-example",},},Nuxt overrides them with the matching environment variables:
NUXT_PUBLIC_OIDC_ISSUER_URIandNUXT_PUBLIC_OIDC_CLIENT_ID. To use the local Keycloak from the examples repo, copy the example file:cp .env.example .env.envpoints the app at the local Keycloak and itsnuxt-oidc-client-ts-exampleclient:NUXT_PUBLIC_OIDC_ISSUER_URI=http://localhost:8080/auth/realms/p2examplesNUXT_PUBLIC_OIDC_CLIENT_ID=nuxt-oidc-client-ts-exampleFor another Keycloak, create
.envthe same way, and setNUXT_PUBLIC_OIDC_ISSUER_URIto your realm's issuer URL andNUXT_PUBLIC_OIDC_CLIENT_IDto the ID of your client. -
Run
pnpm installand thenpnpm dev, and open localhost:3000. -
The structure of the project is similar to the
keycloak-jsversion but with the use of a store and a middleware. -
We'll review where we configure the OIDC client. Open the plugin
app/plugins/oidc.client.ts. It creates a singleUserManagerfrom the runtime config and provides it to the app as$userManager. TheUserManageris theoidc-client-tsobject that logs users in and out.import { UserManager } from "oidc-client-ts";export default defineNuxtPlugin(() => {const { oidcIssuerUri, oidcClientId } = useRuntimeConfig().public;const userManager = new UserManager({authority: oidcIssuerUri,client_id: oidcClientId,redirect_uri: `${window.location.origin}/auth`,silent_redirect_uri: `${window.location.origin}/silent-refresh`,post_logout_redirect_uri: `${window.location.origin}/`,scope: "openid profile email",});return { provide: { userManager } };});oidc-client-tslogs users in with the authorization code flow and PKCE. It keeps the tokens in session storage and renews them before they expire. -
We leverage the
pinialibrary to store the user information and make it easily accessible. Openapp/stores/auth.ts. From within this file, we wrap theUserobject exposed by theoidc-client-tspackage, which theUserManagerevents keep up to date. The store also exposessignIn,signOutand the handlers for the login callbacks.import type { User } from "oidc-client-ts";export const useAuthStore = defineStore("auth", () => {const { $userManager } = useNuxtApp();const user = shallowRef<User | null>(null);const error = shallowRef<string | null>(null);let initialized = false;$userManager.events.addUserLoaded((loadedUser) => {user.value = loadedUser;error.value = null;});$userManager.events.addUserUnloaded(() => {user.value = null;});$userManager.events.addSilentRenewError((renewError) => {error.value = renewError.message;});async function init() {if (initialized) {return;}initialized = true;const storedUser = await $userManager.getUser();user.value = storedUser && !storedUser.expired ? storedUser : null;}function signIn() {return $userManager.signinRedirect({state: { returnTo: window.location.pathname },});}function signOut() {return $userManager.signoutRedirect();}async function handleCallback() {const signedInUser = await $userManager.signinCallback();const state = signedInUser?.state as { returnTo?: string } | undefined;return state?.returnTo ?? "/";}function handleSilentCallback() {return $userManager.signinSilentCallback();}function setError(authError: unknown) {error.value =authError instanceof Error ? authError.message : String(authError);}return {user,error,init,signIn,signOut,handleCallback,handleSilentCallback,setError,};});signInsends the current path along with the login, andhandleCallbackreturns it, so the user comes back to the page they started from.signOutalso ends the Keycloak session. -
There are a couple of pages in play here that the library leverages during the login.
app/pages/auth.vuecompletes the login when Keycloak redirects back to/auth, andapp/pages/silent-refresh.vuehandles silent renewals on/silent-refresh. For instance inapp/pages/auth.vue:<script setup lang="ts">const auth = useAuthStore();const router = useRouter();onMounted(async () => {try {await router.replace(await auth.handleCallback());} catch (callbackError) {auth.setError(callbackError);await router.replace("/");}});</script> -
We have also created a middleware file in
app/middleware/auth.global.ts. Nuxt runs it before every route, and it loads the stored user into the store before the first page renders.export default defineNuxtRouteMiddleware(async () => {await useAuthStore().init();}); -
Now that we have all the things set up, the component
app/components/UserStatus.vuereads the user from the store.storeToRefskeepsuseranderrorreactive.const auth = useAuthStore();const { user, error } = storeToRefs(auth);With this, the user object is now easily available. A simple
v-if="user"allows the app to determine what UI to show, and the buttons callauth.signIn()andauth.signOut(). -
A bit more complicated of a setup, but more elegant in the handling of the logged in flow. The
oidc-client-tslibrary allows for much better fine-tuning of the experience.
Learning more
Phase Two's enhanced Keycloak provides many ways to quickly control and tweak the log in and user management experience. Our blog has many use cases from customizing login pages, setting up magic links (passwordless sign in), and Organization workflows.