SSO Integrationintermediate15 min readApril 19, 2026

Next.js SSO with Keycloak using OIDC

Integrate Keycloak SSO into a Next.js App Router application using Auth.js (next-auth v5). Covers confidential client setup, server and client session access, token refresh, and single logout.

KT

KeycloakPro Team

KeycloakPro Team

Introduction

Next.js applications running on the server can safely store client secrets, which enables the full Authorization Code Flow with a confidential OIDC client — more secure than PKCE-only SPAs because the token exchange happens server-side and access tokens are never exposed to the browser.

This guide uses Auth.js v5 (the framework-agnostic successor to next-auth v4), which has first-class support for the App Router, server components, route handlers, and middleware.

Using a pure SPA architecture? If your Next.js app has output: "export" (no server), use the React PKCE guide instead — there is no server to hold secrets.

Prerequisites

  • Keycloak 24+ with HTTPS
  • Next.js 14+ with App Router
  • Node.js 20+
  • Keycloak admin access

Step 1 — Create the Keycloak confidential client

In the Keycloak admin console:

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

Capability config:

  • Client authentication: ON (confidential client — server holds the secret)
  • Standard flow: ON
  • Direct access grants: OFF
  • Click Next

Login settings:

  • Root URL: http://localhost:3000 (dev) + https://app.example.com (prod)
  • Valid redirect URIs:
    • http://localhost:3000/api/auth/callback/keycloak
    • https://app.example.com/api/auth/callback/keycloak
  • Valid post logout redirect URIs:
    • http://localhost:3000/
    • https://app.example.com/
  • Web origins: http://localhost:3000 and https://app.example.com
Keycloak admin console: new confidential client nextjs-app with Client authentication ON and redirect URI pointing to /api/auth/callback/keycloak
Confidential client — the client secret is held server-side inside Next.js

1.1 — Copy the client secret

Clients → nextjs-app → Credentials → copy the secret.

Keycloak admin console: Credentials tab for nextjs-app showing the Client secret field with a copy button
The client secret is generated automatically — copy it for your .env.local file

Step 2 — Install Auth.js v5

npm install next-auth@beta

Generate a random secret for session encryption:

npx auth secret

This prints a secret — add it to .env.local:

AUTH_SECRET=<generated-secret>
AUTH_KEYCLOAK_ID=nextjs-app
AUTH_KEYCLOAK_SECRET=<paste-from-keycloak>
AUTH_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/YOUR_REALM

Replace YOUR_REALM with your realm name.

Step 3 — Configure Auth.js

Create src/auth.ts:

import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Keycloak({
      clientId: process.env.AUTH_KEYCLOAK_ID!,
      clientSecret: process.env.AUTH_KEYCLOAK_SECRET!,
      issuer: process.env.AUTH_KEYCLOAK_ISSUER!,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      // Persist the access token and refresh token from the initial sign-in
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at;
      }
      return token;
    },
    async session({ session, token }) {
      // Expose the access token to the session
      session.accessToken = token.accessToken as string;
      return session;
    },
  },
});

Create src/app/api/auth/[...nextauth]/route.ts:

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

Step 4 — Protect routes with middleware

Create middleware.ts at the project root:

export { auth as middleware } from "@/auth";

export const config = {
  matcher: [
    // Protect everything except public paths, API routes, and static files
    "/((?!api/auth|_next/static|_next/image|favicon.ico|login).*)",
  ],
};

Unauthenticated requests to protected routes are automatically redirected to the Keycloak login page.

Step 5 — Access session in server components

// src/app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();
  if (!session) redirect("/api/auth/signin");

  return (
    <div>
      <h1>Welcome, {session.user?.name}</h1>
      <p>Email: {session.user?.email}</p>
    </div>
  );
}

5.1 — Access session in client components

"use client";
import { useSession } from "next-auth/react";

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === "loading") return <span>Loading…</span>;
  if (!session) return <a href="/api/auth/signin">Sign in</a>;

  return (
    <div>
      <span>{session.user?.name}</span>
      <button onClick={() => signOut()}>Sign out</button>
    </div>
  );
}

Wrap your root layout with SessionProvider:

// src/app/layout.tsx
import { SessionProvider } from "next-auth/react";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}

Step 6 — Start the dev server and test

npm run dev

Navigate to a protected route (e.g., /dashboard).

Next.js terminal output showing the dev server running and an incoming redirect to Keycloak for authentication
First login — Next.js redirects to Keycloak for authentication

The browser redirects to Keycloak:

Keycloak login screen displayed in the browser during the Next.js Auth.js authorization flow
Keycloak handles credentials — Next.js never sees the user's password

After login, you are redirected back to /dashboard:

Next.js dashboard page after successful Keycloak SSO login showing user name, email, and token expiry from the session
Session established — server components can call await auth() to read user data

Step 7 — Handle token refresh

Keycloak access tokens expire (default: 5 minutes). Add refresh logic to the JWT callback:

callbacks: {
  async jwt({ token, account }) {
    if (account) {
      return {
        ...token,
        accessToken: account.access_token,
        refreshToken: account.refresh_token,
        expiresAt: account.expires_at,
      };
    }

    // Access token still valid
    if (Date.now() < (token.expiresAt as number) * 1000) {
      return token;
    }

    // Refresh the access token
    try {
      const response = await fetch(
        `${process.env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
        {
          method: "POST",
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          body: new URLSearchParams({
            client_id: process.env.AUTH_KEYCLOAK_ID!,
            client_secret: process.env.AUTH_KEYCLOAK_SECRET!,
            grant_type: "refresh_token",
            refresh_token: token.refreshToken as string,
          }),
        }
      );

      const tokens = await response.json();
      if (!response.ok) throw tokens;

      return {
        ...token,
        accessToken: tokens.access_token,
        refreshToken: tokens.refresh_token ?? token.refreshToken,
        expiresAt: Math.floor(Date.now() / 1000 + tokens.expires_in),
      };
    } catch {
      return { ...token, error: "RefreshTokenError" };
    }
  },
},

Step 8 — Configure Keycloak single logout

Keycloak can terminate the Keycloak SSO session when a user signs out of your Next.js app. Update src/auth.ts:

events: {
  async signOut({ token }) {
    // Call Keycloak end-session endpoint to terminate the SSO session
    if (token?.refreshToken) {
      await fetch(
        `${process.env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`,
        {
          method: "POST",
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          body: new URLSearchParams({
            client_id: process.env.AUTH_KEYCLOAK_ID!,
            client_secret: process.env.AUTH_KEYCLOAK_SECRET!,
            refresh_token: token.refreshToken as string,
          }),
        }
      );
    }
  },
},

Step 9 — Deployment

Set environment variables in your hosting platform (Vercel, Cloudflare, AWS, etc.):

Vercel project settings showing Environment Variables panel with AUTH_SECRET, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET, and AUTH_KEYCLOAK_ISSUER configured for the production environment
Set all four environment variables in your deployment platform — never commit them to source control

Additional settings for production:

  • Set AUTH_URL to your production URL if the library cannot auto-detect it (e.g., behind a reverse proxy): AUTH_URL=https://app.example.com
  • For Cloudflare, Nginx, or other reverse proxies: ensure X-Forwarded-Proto: https is passed so Auth.js sets secure cookies correctly.
  • Keycloak client must list your production URL in Valid redirect URIs and Web origins.

Step 10 — Troubleshooting common issues

Auth.js sets __Secure- prefixed cookies when it detects HTTPS. If your proxy does not pass X-Forwarded-Proto, the library thinks it is on HTTP, sets unsecured cookies, and the browser rejects them. Fix: add proxy_set_header X-Forwarded-Proto $scheme; in Nginx.

The OIDC state parameter is stored in a cookie before the redirect. If the cookie is missing on return, it is usually caused by a cross-site redirect that strips cookies (SameSite policy), or the redirect URI domain does not match the cookie domain. Ensure your callback URL matches exactly what Keycloak redirects to.

Clock skew

JWT validation fails if the server clock is ahead of or behind Keycloak by more than the token's iat/nbf window. Sync clocks with NTP.

Refresh token reuse error

Keycloak rejects a refresh token that has already been used when refresh token rotation is enabled. This can happen if two requests race to refresh simultaneously. Implement a mutex or use Auth.js's built-in token expiry guard before triggering a refresh.

Step 11 — Production checklist

  • AUTH_SECRET is a random 32-byte hex string (generated by npx auth secret)
  • AUTH_URL is set in production if behind a reverse proxy
  • Keycloak client lists production URLs in Valid redirect URIs and Web origins
  • Token refresh callback implemented — access tokens auto-renew
  • Single logout configured — Keycloak session terminated on signout
  • Sensitive env vars (AUTH_KEYCLOAK_SECRET) are not committed to source control
  • Session access tested in both server components (await auth()) and client components (useSession)
  • Middleware route matcher reviewed — ensure public routes (landing page, API) are not blocked

Need help integrating Next.js with Keycloak?

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

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

See integration packages