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.
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:
- Clients → Create client
- Client type:
OpenID Connect - Client ID:
nextjs-app - 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/keycloakhttps://app.example.com/api/auth/callback/keycloak
- Valid post logout redirect URIs:
http://localhost:3000/https://app.example.com/
- Web origins:
http://localhost:3000andhttps://app.example.com

1.1 — Copy the client secret
Clients → nextjs-app → Credentials → copy the secret.

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

The browser redirects to Keycloak:

After login, you are redirected back to /dashboard:

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.):

Additional settings for production:
- Set
AUTH_URLto 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: httpsis 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
Session cookie missing behind proxy
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.
state cookie was missing
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_SECRETis a random 32-byte hex string (generated bynpx auth secret) -
AUTH_URLis 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.