Keycloak with Quarkus and Next.js/Auth.js: Full-Stack Authentication

Main image of article

you can also read this article in Medium -> click here


Every developer has to face this at some point — implementing authentication for a full-stack project — frontend and backend.

We’ll look closely at the process by building an application and securing it with Keycloak — an open-source identity and access management solution, that handles user login, registration, and token-based security (like OAuth 2.0 or OpenID Connect. We’ll focus on something practical here — Keycloak is used by companies worldwide and provides a set of really killer features. Chances are, you’re going to face Keycloak as an authentication solution at some point in your career, so let’s build something small and play around with it, to understand how it works and what it can do for us.

We’ll use Next.js for our frontend and Quarkus for the backend. Next.js is particularly interesting when it comes to authentication, because it can mix server-side rendering (SSR) and client-side interactions, which can create some challenges when getting the user session data. Quarkus has become my go-to backend technology for building fast, scalable, and modern APIs. Together, they create a seamless flow: Keycloak authenticates users and issues tokens, Quarkus validates those tokens and secures the API, and Next.js consumes the API while delivering a smooth sign-in user experience — ensuring your application is secure. Here is a diagram of how all three components play together:

Diagramm overview

Keep in mind that this is somewhat simplified and doesn’t discuss some of the steps (like token exchange or the signing of JWT tokens in the backend) in detail.

Now let’s start with the backend and zoom in on the diagram a bit.

Backend

We’ll create a simple Quarkus backend application and use its Dev Services to spin up a Keycloak container on start. The backend will serve a single endpoint /protected to demonstrate the Keycloak integration.

Here is our diagram, but zoomed in on the backend part:

Diagramm backend

JWT tokens, obtained by our frontend through the user login, are signed with a private key by Keycloak. The tokens are then sent on every request to the backend and Quarkus verifies the token’s signature using Keycloak’s public key, which it obtains beforehand (e.g., request to JWKS endpoint, usually /auth/realms/MY-REALM/protocol/openid-connect/certs). The key is usually cached (See steps 2.1, 2.2., 2.3 from the diagram) to avoid repeated requests.

We’re going to use the quarkus-oidc extension where the verification of the JWT token happens under the hood automatically. That’s actually pretty neat — we just need the right configuration in our applciation.properties and get this out of the box. Let’s go into some details and set up our backend.

1. Create a Quarkus Java Application

Let’s start by creating a Quarkus Java Application for our example. Go to https://code.quarkus.io/:

  1. Name your project
  2. Choose Gradle as Build Tool (optional, if you want to follow along. Maven would of course also work)
  3. Preferably — Java 21
  4. Add the quarkus-rest-jackson and the quarkus-oidc dependencies.
Quarkus code dev

Download the project and open it in your favourite IDE — I used IntelliJ. In src/main/java you should be able to see some automatically generated code we get from the starter, probably GreetingResource — delete it and add a new class with the following content:

package com.tsvetkov;
 
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
 
@Path("/protected")
public class ProtectedResource {
 
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String protectedInfo() {
        return "This is highly sensitive private protected secret information";
    }
}

your folder structure should look something like this:

Backend Code structure

Then put the following in your src/resources/application.properties:

quarkus.keycloak.devservices.port=8082
quarkus.oidc.client-id=backend
quarkus.oidc.application-type=service
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

Let’s explain what these properties do:

quarkus.keycloak.devservices.port — Because we added the quarkus-oidc extension, the Keycloak Dev Service will be enabled and a Keycloak docker container will be started automatically on start of the application. Through this property, we set the port on which the Keycloak container will be started, in our case — 8082

quarkus.oidc.client-id=backend — this is the client-id for our application — will get automatically created when the container starts.

quarkus.oidc.application-type=service — specifies how the application interacts with OpenID Connect (OIDC). We use type service (for backend services using client credentials), but there are other options here like webapp for browser-based login, or hybrid (supporting both webapp and service authentication).

The last two properties configure all of our endpoints to be protected and need authentication.

2. Run Quarkus Backend App

Now, we can start our Quarkus application and check if everything works as expected. Just go to the terminal in the project folder and execute:

./gradlew quarkusDev

As already mentioned, this will automatically start a Keycloak container for us. We can go to http://localhost:8082 (username: admin, password: admin) and access the Keycloak UI!

Keycloak UI

The Keycloak Dev Services set up some things for us already!

A new realm quarkus got created, along with two roles — admin and user and two users: Alice who has admin and user roles assigned, and Bob who only has the user role. The client-idbackend” we set in our application.properties also got automatically created. That’s already a pretty nice Keycloak server setup for us to play around with.

Now let’s try to hit the /protected endpoint of our app (which is running on http://localhost:8080). You can use Postman or any other API Platform to test this:

Postman 1

Exactly as expected — we get a 401 Unauthorized. What we need now is to authenticate ourselves. We need to hit this endpoint with a valid JWT Token in order for our backend to verify we’re an allowed user. We can obtain a JWT token from the Keycloak token endpoint, which looks like this:

http://<KEYCLOAK_SERVER>/realms/<REALM_NAME>/protocol/openid-connect/token

Let’s use Postman again to get a valid JWT token. We’ll use grant_type password for this. This is not recommended for production apps, but here we just want to test our endpoint. Normally the JWT token will be available in our frontend after login, and that’s where we’ll send the requests to the backend in our Full-Stack Application. We’ll get to this in a minute, but first, let’s get a valid JWT token to test our /protected endpoint

Postman 2

Here we send a request to the Keycloak token endpoint with our backend client-id and user credentials (user: alice, password: alice). Now we can use the access_token to send a request to our /protected endpoint:

Postman 3

That’s how easy it is to set up Keycloak with Quarkus for development purposes. We already have our Backend App and a Keycloak Instance running!

Our backend can validate tokens issued from our Keycloak instance — great! Now let’s zoom in on the frontend part. We now need to allow our users to open the browser, navigate to our website, authenticate with valid credentials, see some content, and get the protected data from our backend.

Frontend

We’ll create a simple Next.js application and use the free open-source Auth.js authentication library.

Why use Auth.js? Well, it simplifies a lot of stuff for us. Here are a couple of very useful features we’ll be using:

  1. Auth.js provides a built-in Keycloak provider. That means, we don’t have to manually implement OAuth 2.0 or OpenID Connect (OIDC) flows. It also automatically exposes a REST API with endpoints like /api/auth/signin that trigger the sign-in flow of the respective providers, or /api/auth/callback/:provider (in our case :provider is Keycloak) that can handle the returning requests from OAuth services during sign-in.

  2. Auth.js provides the useSession hook and convenient functions to access the session state on both the client and server sides. This is especially useful in Next.js, where developers often mix server-side rendering (SSR), static site generation (SSG), and client-side interactions.

  3. Token refresh: Keycloak issues short-lived access tokens and longer-lived refresh tokens. Auth.js can automatically handle token refreshing for us, which is awesome.

  4. Middleware for Route Protection: Auth.js integrates with Next.js middleware to protect certain routes. We can easily restrict access to specific pages.

It’s also worth mentioning, that the library also poses some challenges and could create some difficulties, but we’ll talk about these later.

Let’s explore how Keycloak, Next.js, and Auth.js work together in more detail:

Diagramm frontend

Let’s discuss this briefly, as this diagram shows some details that weren’t mentioned in the previous drawings (e.g. Authorization Code Flow).

  • User requests protected page (e.g. /protected-page)
  • Auth.js automatically checks for valid session
  • Initiate the authentication flow

Auth.js redirects the user to its own authentication endpoint — /api/auth/signin which initiates the Keycloak authentication process. It contains the logic to construct an OpenID Connect authorization URL which contains information like Redirect URI, Client ID, Response type (typically code for authorization code flow) and Required scopes

  • Keycloak Authentication The user is then redirected to the Keycloak’s auth endpoint with the above-mentioned parameters and the URL looks like this: https://<KEYCLOAK_SERVER>/realms/<REALM_NAME>/protocol/openid-connect/auth

  • User enters credentials and Keycloak validates them against its user database. If valid — Keycloak generates an authorization code and redirects back to the Next.js application — /api/auth/callback/keycloak , where the authorization code is included as a query parameter.

(I won’t get into details in this tutorial on how the authorization code flow works, but you can read more about it here.)

  • Exchange the Authorization code for Tokens Auth.js receives the authorization code and makes a server-side request to Keycloak’s token endpoint. The request includes the authorization code among other things like the Redirect URI.

Keycloak validates the request and returns the Access token (JWT), the refresh token, and the ID token.

  • Session Tokens Auth.js validates the tokens and extracts the user information from the ID token. It then creates the session and sets the HttpOnly cookie in the browser. After that, the user is redirected to the requested protected page.

1. Create a simple Next.js App with Auth.js

Alright, now let’s create a Next.js Application and play around with our setup.

Go to your terminal and execute:

npx create-next-app@latest

Choose a name for your project and then select “yes” on all the prompted questions.

Then open the project in your favourite editor and create the following files:

app/nav.tsx and app/protected-page/page.tsx

your folder structure should look like this:

Frontend code structure

Let’s do things step by step and just write some minimal initial code for our application.

2. Initial code

Let’s start from our layout.tsx which should look like this in order for you to follow along:

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Nav from "./nav";
 
const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});
 
const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});
 
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <Nav></Nav>
        <main className="container mx-auto p-4">{children}</main>
      </body>
    </html>
  );
}

You notice that this file contains the Nav element from /app/nav.tsx so let’s code that next:

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
 
function Nav() {
  const pathname = usePathname(); // Gets the current route
 
  return (
    <nav className="bg-gray-800 p-4">
      <div className="flex justify-center gap-x-60 items-center">
        <div className="flex gap-x-5">
          <Link href="/" className={`${pathname === "/" ? "text-yellow-500" : "text-white"} hover:text-gray-300`}>
            Home
          </Link>
          <Link href="/protected-page" className={`${pathname === "/protected-page" ? "text-yellow-500" : "text-white"} hover:text-gray-300`}>
            Protected
          </Link>
        </div>
        <button onClick={() => {}} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
          Login
        </button>
      </div>
    </nav>
  );
}
 
export default Nav;

The app/page.tsx component will be our public home page. We’ll keep things minimal too:

export default function Home() {
  const username = "You're not logged in.";
  return (
    <div className="text-center">
      <h1 className="text-3xl font-bold mb-4">Welcome Home</h1>
      <h2 className="text-3xl text-orange-400 mb-4"> {username}</h2>
      <p className="text-gray-600">This is a public page anyone can see</p>
    </div>
  );
}

and lastly, our /protected-page/page.tsx which will set up a page that we want to secure with Keycloak at http://localhost:3000/protected-page (Our navigation has a link to it too [see two code blocks above]):

import React from "react";
 
function ProtectedPage() {
  return (
    <div className="text-center">
      <h1 className="text-3xl font-bold mb-4">Protected Page</h1>
      <p className="text-gray-800">
        Welcome <span className="text-green-800">(Some User)!</span>
        <br />
        <br />
        This is a protected page.
      </p>
    </div>
  );
}
 
export default ProtectedPage;

Now go to your terminal, execute npm run dev and you should see this:

Layout 1
Layout 2

3. Add Auth.js

Now it gets interesting — we will install Auth.js and set up its Keycloak provider. Go to your terminal and execute:

npm install next-auth@beta

Auth.js CLI also provides a neat way to generate secrets. We’ll need to create one for our setup, so let’s do this now. Execute the following, which will create an .env.local file for us and generate a secret there:

npx auth secret

Your .env.local file should then look like this:

Env file

We’ll use a public Keycloak client for our frontend, so we won’t need a secret actually, but the Auth.js Provider needs one (even if its null, see here)

Next, we’ll create an auth.js file in the root of our project and set up the actual configuration of the Keycloak provider:

import NextAuth, { NextAuthResult } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
 
const authData: NextAuthResult = NextAuth({
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_CLIENT_ID,
      issuer: process.env.KEYCLOAK_ISSUER,
    }),
  ],
});
 
export const { handlers, signIn, signOut, auth } = authData;

Also, add a Route Handler under /app/api/auth/[...nextauth]/route.ts :

import { handlers } from "@/auth";
export const { GET, POST } = handlers;

These are used to expose the REST API endpoints (such /api/auth/signin for redirecting users to the Keycloak login page).

You probably noticed some environmental variables here, which we do need to add to our .env.local file.

KEYCLOAK_CLIENT_ID=frontend
KEYCLOAK_ISSUER=http://localhost:8082/realms/quarkus

The KEYCLOAK_ISSUER is our Keycloak endpoint and the KEYCLOAK_CLIENT_ID points to our Keycloak client — “frontend”. You probably guessed this — we need to create this ourselves in the Keycloak UI. Let’s go to http://localhost:8082/ again and do this:

Keycloak Client 1
Keycloak Client 2
Keycloak Client 3
Keycloak Client 2

I hope this is clear enough and you have managed to create your** “frontend” **client 👍

4. Sign-in

Now it’s time to add the Sign-in functionality to our web app. Go to our nav.tsx and let’s use Auth.js’ signIn function to log in when we click the Login button:

<button onClick={() => signIn("keycloak")} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
  Login
</button>

Now go to our application, click on the Login button in our naviagtion and you will see the Keycloak Login Screen! Use the user “alice” to log in and you can even see your session in the Keycloak UI under (http://localhost:8082/admin/master/console/#/quarkus/sessions)

Login 1
Login 2

Now let’s use some of that login information in our application! It would be nice to show our user's username on the home page. Our Home component (/app/page.tsx)is a server-rendered component, so let’s see how to get our user information in a server-side component:

import { auth } from "@/auth";
 
export default async function Home() {
  const session = await auth();
  const username = session?.user ? session.user.name : "You're not logged in.";
 
  return (
    <div className="text-center">
      <h1 className="text-3xl font-bold mb-4">Welcome Home</h1>
      <h2 className="text-3xl text-orange-400 mb-4"> {username}</h2>
      <p className="text-gray-600">This is a public page anyone can see</p>
    </div>
  );
}

We use Auth.js’ auth() function to get information about our session, and from there, we can get our username. If we’re not logged in, we just show the “You’re not logged in.” message from before. If you go to our application on http://localhost:3000/ you’ll be able to see our username “alice” in the Home component!

Alice logged in

5. Sign-out

Signing out looks similar to what we had for our login — Let’s go to our nav.tsx component and change the Login button to “Logout” if the user is logged in. That means that we need user information in our Nav component, which is a client-side component, so let’s see how to get our user information on the client side:

"use client";
import { signIn, signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
 
function Nav() {
  const pathname = usePathname(); // Gets the current route
  const session = useSession();
 
  return (
    <nav className="bg-gray-800 p-4">
      <div className="flex justify-center gap-x-60 items-center">
        <div className="flex gap-x-5">
          <Link href="/" className={`${pathname === "/" ? "text-yellow-500" : "text-white"} hover:text-gray-300`}>
            Home
          </Link>
          <Link href="/protected-page" className={`${pathname === "/protected-page" ? "text-yellow-500" : "text-white"} hover:text-gray-300`}>
            Protected
          </Link>
        </div>
        {!session.data ? (
          <button onClick={() => signIn("keycloak")} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
            Login
          </button>
        ) : (
          <button onClick={() => signOut()} className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
            Logout
          </button>
        )}
      </div>
    </nav>
  );
}
 
export default Nav;

We used Auth.js' useSession hook to get the login information of our user and added a condition to show the login button if we’re logged-out and the logout button if we’re logged in. We also added the signOut call on click on the logout button.

We also need to wrap all our components that access the useSession hook in the Auth.js’ <SessionProvider> . Go to layout.tsx and add it there:

<html lang="en">
  <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
    <SessionProvider>
      <Nav></Nav>
      <main className="container mx-auto p-4">{children}</main>
    </SessionProvider>
  </body>
</html>

Now if we log in, we see the logout button in the navigation:

Alice Logout button

That’s already pretty good! But we can still go to the /protected-page even if we’re logged out 🤔

6. Authorize access to a page

In order to forbid the access to /protected-page for unauthenticated users, we need to add the following middleware.ts to the root of our project:

import { NextResponse } from "next/server";
import { auth } from "./auth";
 
export default auth((req) => {
  if (!req.auth && req.nextUrl.pathname === "/protected-page") {
    return NextResponse.redirect(new URL(`/api/login?callbackUrl=${encodeURIComponent(req.nextUrl.pathname)}`, req.nextUrl.origin));
  }
});
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

Let’s see what happens here, and why it’s a bit tricky when we use Auth.js with providers when it comes to the middleware.

We use the auth configuration object as a wrapper for our Middleware, following the Auth.js documentation. Inside we write our little logic that checks if a user is unauthenticated and wants to access the /protected-page => In this case, we redirect to /api/login with a callback URL as a parameter (in this case — /protected-page — this is where we want to land after the login). The /api/login is a route that we need to create in order to call the signIn method with “Keycloak” as a provider. The NextResponse.redirect code from above makes a GET Request to our own route which we can configure to trigger the signIn function from Auth.js. I hope that was clear enough, but if you want to dig a bit deeper into why this is the most elegant way to go for now, you can read the discussion here.

So now create a route.ts file under /api/login with the following:

import { signIn } from "@/auth";
import { NextRequest } from "next/server";
 
export async function GET(req: NextRequest) {
  const callback = req.nextUrl.searchParams.get("callbackUrl");
  return signIn("keycloak", callback ? { redirectTo: callback } : undefined);
}

This implements the logic, explained above.

7. Display data from the backend (challenges)

Alright, now that we have protected our /protected-route , let’s display some sensitive data in it from our backend. We’ll modify our /protected-page/page.tsx:

import { auth } from "@/auth";
import React from "react";
 
const fetchBackendData = async () => {
  const response = await fetch("http://localhost:8080/protected");
  if (response.status === 401) {
    return "Unauthorized: Please login again";
  }
  if (!response.ok) {
    throw new Error(`Error! Status: ${response.status}`);
  }
  const backendData = await response.json();
  return backendData;
};
 
async function ProtectedPage() {
  const session = await auth();
  const username = session?.user?.name;
 
  const backendData = await fetchBackendData();
 
  return (
    <div className="text-center">
      <h1 className="text-3xl font-bold mb-4">Protected Page</h1>
      <p className="text-gray-800">
        Welcome <span className="text-green-800">{username}!</span>
        <br />
        <br />
        This is a protected page.
        <br />
        <br />
      </p>
      <div>
        <div>This is the data from the backend:</div>
        <div className="text-blue-500">{backendData}</div>
      </div>
    </div>
  );
}
 
export default ProtectedPage;

This stays a server component and we can now easily take our session data from the auth object. We’ll display the username here too, like on the Home page, but we’ll also make a request to our backend and display the data from there.

🤔 🤔 🤔 Hm, the code above looks suspicious …

I hope you already noticed why this component will now always return “Unauthorized: Please login again” for the backend data. We are making an unauthorized request to our backend!

Now we come to the most challenging part — we have a couple of options here.

Options for secure communication with the backend

  1. Direct API Calls with Bearer Tokens: Store tokens in the session (Tokens are exposed in client-side code)

  2. Secure Cookie-Based Authentication: Use HTTP-only, secure cookies: The browser sends the cookie automatically with every request, but additional configuration is needed on the backend.

  3. Backend-for-Frontend (BFF) Pattern: Using Next.js API Routes as a Secure Proxy and accessing the token only server-side. Create API routes in Next.js that proxy requests to our Quarkus backend. The access token is added to the request on the server side and this way sensitive token operations do not happen in the browser. (Can increase complexity and latency though)

At the time of writing, Auth.js recommends only the first way in its documentation. As briefly mentioned in the explanation above — this way is not perfect and we should understand that it hides some security risks (access token available client-side). I’ll discuss only this strategy in this article because our goal is to play around here, but keep in mind that this way is definitely not mature enough for real-world applications.

Anyways, let’s get into it:

Direct API Calls with Bearer Tokens

Once a user is authenticated via Auth.js with Keycloak, we can obtain the JWT token, include it in our session, and add this token in the headers of our requests (e.g., Authorization: Bearer <access_token> Here is a link if you’d like a quick refresher on the basics).

In order to implement this approach, we actually have to save the token in our session object. Here is how this can happen in Auth.js — we’ll use the jwt and session callbacks. The jwt one, does the following:

This callback is called whenever a JSON Web Token is created (i.e. at sign in) or updated (i.e whenever a session is accessed in the client). Anything you return here will be saved in the JWT and forwarded to the session callback. There you can control what should be returned to the client. Anything else will be kept from your frontend. The JWT is encrypted by default via your AUTH_SECRET environment variable.

Let’s modify our auth.ts accordingly:

import NextAuth, { NextAuthResult } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
 
const authData: NextAuthResult = NextAuth({
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_CLIENT_ID,
      issuer: process.env.KEYCLOAK_ISSUER,
    }),
  ],
  callbacks: {
    jwt({ token, account }) {
      if (account) token.accessToken = account!.access_token;
      return token;
    },
    session({ session, token }) {
      if (token.accessToken) session.accessToken = token.accessToken;
      return session;
    },
  },
});
 
export const { handlers, signIn, signOut, auth } = authData;

Briefly explained: We get the token from the account which contains information about the provider being used (Keycloak in our case) and also the different tokens returned by Keycloak. Then we add it to the token object, which gets passed to the session callback, and there we attach it to the session and make it available in our application.

Now let’s get back to our /protected-page/page.tsx and modify the backend call to include the Authorization Header:

import { auth } from "@/auth";
import React from "react";
 
const fetchBackendData = async (accessToken: string) => {
  const response = await fetch("http://localhost:8080/protected", {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
  });
  if (response.status === 401) {
    return "Unauthorized: Please login again";
  }
  if (!response.ok) {
    throw new Error(`Error! Status: ${response.status}`);
  }
  const backendData = await response.text();
  return backendData;
};
 
async function ProtectedPage() {
  const session = await auth();
  const username = session?.user?.name;
 
  const backendData = await fetchBackendData(session.accessToken);
 
  return (
    <div className="text-center">
      <h1 className="text-3xl font-bold mb-4">Protected Page</h1>
      <p className="text-gray-800">
        Welcome <span className="text-green-800">{username}!</span>
        <br />
        <br />
        This is a protected page.
        <br />
        <br />
      </p>
      <div>
        <div>This is the data from the backend:</div>
        <div className="text-blue-500">{backendData}</div>
      </div>
    </div>
  );
}
 
export default ProtectedPage;

Keep in mind that the security risks we talked about before are mitigated as this here is a server component, but if we use the useSession() hook in a client-side component, a malicious script could potentially extract the token from the session object, as it is accessible in the client-side JavaScript context in the Browser.

Almost everything looks well now:

Login video

that looks very good, but:

You will notice one small thing — the logout doesn’t quite work:

If you click on Logout -> The Auth.js session is destroyed but Keycloak maintains his session, which keeps the user weirdly logged in.

To fix this, we need to call the Keycloak-Logout URL additionally, after signOut from Auth.js. To correctly call the Keycloak-Logout URL, which is: http://<KEYCLOAK_URL>/realms/<KEYCLOAK_REALM>/protocol/openid-connect/logout one must also add the id_token as the id_token_hint parameter. This means, we need to extract the id_token from the account in our jwt callback as well. Here is how this comes together at the end in our auth.ts:

import NextAuth, { NextAuthResult } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
 
const authData: NextAuthResult = NextAuth({
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_CLIENT_ID,
      issuer: process.env.KEYCLOAK_ISSUER,
    }),
  ],
  callbacks: {
    jwt({ token, account }) {
      if (account) {
        token.accessToken = account!.access_token;
        token.id_token = account!.id_token;
      }
      return token;
    },
    session({ session, token }) {
      if (token.accessToken) session.accessToken = token.accessToken;
      return session;
    },
  },
  events: {
    async signOut({ token }) {
      const logOutUrl = new URL(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/logout`);
      logOutUrl.searchParams.set("id_token_hint", token.id_token!);
      const response = await fetch(logOutUrl);
      if (!response.ok) {
        console.error("Logout failed");
      }
    },
  },
});
 
export const { handlers, signIn, signOut, auth } = authData;

We call the Keycloak-Logout URL on signOut and add the id_token to the token object, in order for it to be available in our signOut event.

Now we have everything!

Final Thoughts

Implementing full-stack authentication with Keycloak, Quarkus, and Next.js (via Auth.js) is not really a breeze — it involves multiple frameworks, covers backend and frontend paradigms, and battles with Auth.js specifics. In this article, we’ve walked through a working example with real code to demonstrate how these pieces can come together and I hope the additional diagrams and explanations prove useful to developers who want to gain a deeper understanding of the topic.

That said, it's important to mention that Auth.js, while perfect for some use cases, still feels relatively immature for complex enterprise scenarios like this. Injecting the access token into the session object to make it available to client-side hooks like useSession might look convenient but it does pose some risks by exposing sensitive tokens to the browser. So while it's technically possible, it's not always advisable—especially in applications, where high security is a priority.

I'd love to hear about other developers' experiences with this stack as well!

Authentication and authorization are complex topics, although many people seem to underestimate them and approach them with little understanding because there are many great frameworks nowadays that abstract the great complexity that's hidden behind the scenes here. However, with the right approach — and a deep understanding of the limitations and trade-offs — this can become a great strength and a valuable skill in a developer's toolset

Thanks for reading!