Add Authentication to Your Next.js App with Keycloak

Guilliano Molaire Guilliano Molaire Updated March 18, 2026 8 min read

Last updated: March 2026

If you’re building a Next.js application and need enterprise-grade authentication, Keycloak is one of the strongest open-source options available. It gives you single sign-on (SSO), multi-factor authentication, social login, and identity federation out of the box — without locking you into a proprietary vendor.

This tutorial walks through integrating Keycloak with a Next.js 14+ App Router application using Auth.js v5 (the successor to NextAuth.js). By the end, you’ll have working authentication with protected routes, server component auth, middleware-based route protection, automatic token refresh, and role-based access control.

Prerequisites

Before starting, you’ll need:

Step 1: Set Up a Keycloak Client

In the Keycloak Admin Console, create a new client for your Next.js app:

  1. Navigate to Clients > Create client
  2. Set Client ID to nextjs-app
  3. Set Client type to OpenID Connect
  4. Enable Client authentication (this makes it a confidential client)
  5. Under Valid redirect URIs, add http://localhost:3000/api/auth/callback/keycloak
  6. Under Valid post logout redirect URIs, add http://localhost:3000
  7. Under Web origins, add http://localhost:3000

After saving, go to the Credentials tab and copy the Client secret. You’ll need it for your environment variables.

If you want to configure roles, go to the Roles tab within your client and create roles like admin and user. Then assign them to users under Users > Role mappings. For a deeper understanding of role-based access in Keycloak, see our RBAC feature overview or our post on understanding RBAC.

Step 2: Install Dependencies

npm install next-auth@beta

Auth.js v5 is published under the next-auth@beta tag. The @beta tag provides the v5 release with full App Router support.

Your package.json dependencies should look something like:

{
  "dependencies": {
    "next": "^14.2.0",
    "next-auth": "^5.0.0-beta.25",
    "react": "^18.3.0",
    "react-dom": "^18.3.0"
  }
}

Step 3: Configure Environment Variables

Create a .env.local file in your project root:

# Auth.js
AUTH_SECRET="your-random-secret-at-least-32-characters"

# Keycloak
AUTH_KEYCLOAK_ID="nextjs-app"
AUTH_KEYCLOAK_SECRET="your-client-secret-from-keycloak"
AUTH_KEYCLOAK_ISSUER="http://localhost:8080/realms/your-realm"

# App
NEXTAUTH_URL="http://localhost:3000"

Generate a strong AUTH_SECRET with:

openssl rand -base64 32

The AUTH_KEYCLOAK_ISSUER must point to your Keycloak realm’s issuer URL. This is the base URL that Auth.js uses for OIDC Discovery to find the authorization, token, and userinfo endpoints automatically.

Step 4: Create the Auth Configuration

Create auth.ts in your project root:

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

declare module "next-auth" {
  interface Session {
    accessToken?: string;
    roles?: string[];
    error?: string;
  }
}

declare module "next-auth" {
  interface JWT {
    accessToken?: string;
    refreshToken?: string;
    expiresAt?: number;
    roles?: string[];
    error?: string;
  }
}

export const { handlers, signIn, signOut, auth } = 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 }) {
      // On initial sign-in, persist the tokens
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at;

        // Extract roles from the access token
        const decoded = JSON.parse(
          Buffer.from(account.access_token!.split(".")[1], "base64").toString()
        );
        token.roles = [
          ...(decoded.realm_access?.roles || []),
          ...(decoded.resource_access?.["nextjs-app"]?.roles || []),
        ];

        return token;
      }

      // Return the token if it hasn't expired
      if (Date.now() < (token.expiresAt as number) * 1000) {
        return token;
      }

      // Token has expired, attempt to refresh
      return await refreshAccessToken(token);
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken as string;
      session.roles = token.roles as string[];
      if (token.error) {
        session.error = token.error as string;
      }
      return session;
    },
  },
});

async function refreshAccessToken(token: any) {
  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 refreshed = await response.json();

    if (!response.ok) {
      throw new Error("Token refresh failed");
    }

    return {
      ...token,
      accessToken: refreshed.access_token,
      refreshToken: refreshed.refresh_token ?? token.refreshToken,
      expiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
    };
  } catch (error) {
    console.error("Error refreshing access token:", error);
    return { ...token, error: "RefreshAccessTokenError" };
  }
}

This configuration does several important things:

  1. Persists Keycloak tokens in the JWT session on initial sign-in
  2. Extracts roles from both realm-level and client-level role mappings
  3. Automatically refreshes expired tokens using the refresh token flow
  4. Surfaces token errors to the session so your UI can handle re-authentication

For a deeper understanding of token lifecycle management, see our guide on JWT token lifecycle management.

Step 5: Set Up the Route Handler

Create the API route that Auth.js uses for authentication callbacks:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

This single file handles all of the OAuth 2.0 / OIDC flow endpoints — authorization redirects, callback processing, session management, and sign-out.

Step 6: Add the Session Provider

Create a client-side session provider component:

// app/providers.tsx
"use client";

import { SessionProvider } from "next-auth/react";

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}

Then wrap your root layout:

// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Step 7: Protect Routes with Middleware

Create middleware.ts in your project root to protect routes at the edge:

// middleware.ts
import { auth } from "@/auth";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
  const isOnAdmin = req.nextUrl.pathname.startsWith("/admin");
  const isOnAuthPage = req.nextUrl.pathname.startsWith("/api/auth");

  // Allow auth API routes
  if (isOnAuthPage) return;

  // Redirect unauthenticated users to sign in
  if ((isOnDashboard || isOnAdmin) && !isLoggedIn) {
    return Response.redirect(new URL("/api/auth/signin", req.nextUrl));
  }

  // Check for admin role on admin routes
  if (isOnAdmin && isLoggedIn) {
    const roles = req.auth?.roles || [];
    if (!roles.includes("admin")) {
      return Response.redirect(new URL("/unauthorized", req.nextUrl));
    }
  }
});

export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*"],
};

The middleware runs at the edge before your page renders, so unauthorized users never even hit your server components. This gives you both a security boundary and a clean user experience.

Step 8: Use Auth in Server Components

One of the biggest advantages of Auth.js v5 with App Router is native server component support. You can call auth() directly in any server component:

// 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");
  }

  // Handle token refresh errors
  if (session.error === "RefreshAccessTokenError") {
    redirect("/api/auth/signin");
  }

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

      <h2>Your Roles</h2>
      <ul>
        {session.roles?.map((role) => (
          <li key={role}>{role}</li>
        ))}
      </ul>

      {session.roles?.includes("admin") && (
        <div>
          <h2>Admin Section</h2>
          <p>You have admin access.</p>
        </div>
      )}
    </div>
  );
}

No useSession hook, no loading states, no hydration mismatch — the session data is available synchronously on the server.

Step 9: Use Auth in Client Components

For client components that need session data, use the useSession hook:

// components/UserMenu.tsx
"use client";

import { useSession, signIn, signOut } from "next-auth/react";

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

  if (status === "loading") {
    return <div>Loading...</div>;
  }

  if (!session) {
    return (
      <button onClick={() => signIn("keycloak")}>
        Sign in with Keycloak
      </button>
    );
  }

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

Step 10: Protect API Routes

Create protected API routes that validate the session and use the Keycloak access token to call downstream services:

// app/api/protected/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";

export const GET = auth(async function GET(req) {
  if (!req.auth) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Use the access token to call a backend API
  const backendResponse = await fetch("https://api.example.com/data", {
    headers: {
      Authorization: `Bearer ${req.auth.accessToken}`,
    },
  });

  const data = await backendResponse.json();
  return NextResponse.json(data);
});

You can use our JWT Token Analyzer to inspect the access tokens Keycloak issues and verify they contain the claims and roles you expect.

Step 11: Handle Keycloak Logout Properly

Keycloak requires a backchannel logout to fully terminate the session. Without this, the user’s session remains active in Keycloak even after they sign out of your app:

// app/api/auth/logout/route.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export async function GET() {
  const session = await auth();

  if (!session) {
    redirect("/");
  }

  // Build the Keycloak logout URL
  const issuer = process.env.AUTH_KEYCLOAK_ISSUER!;
  const logoutUrl = new URL(`${issuer}/protocol/openid-connect/logout`);
  logoutUrl.searchParams.set(
    "post_logout_redirect_uri",
    process.env.NEXTAUTH_URL!
  );
  logoutUrl.searchParams.set("client_id", process.env.AUTH_KEYCLOAK_ID!);

  redirect(logoutUrl.toString());
}

Then update your sign-out button to hit this endpoint instead of using the default signOut():

<a href="/api/auth/logout">Sign out</a>

This ensures the user’s session is terminated on both the application side and the Keycloak side.

Complete Project Structure

Here’s what your project should look like when everything is wired up:

Next.js project structure showing auth.ts, middleware.ts, app directory with routes, and components

Troubleshooting Common Issues

“OIDC Discovery failed”: Make sure your AUTH_KEYCLOAK_ISSUER URL is correct and accessible from your Next.js server. It should look like http://localhost:8080/realms/my-realm (not /auth/realms/ which was the old Keycloak path format).

Roles not appearing: Ensure your client has the proper role scope mappings. In Keycloak, go to Client Scopes > roles > Mappers and verify that the realm roles and client roles mappers are configured and that “Add to access token” is enabled.

Token refresh failing: Check that your client’s refresh token settings are correct. In your client configuration under Advanced > Advanced Settings, ensure the refresh token lifespan is set appropriately. See our post on configuring CORS with Keycloak if you’re seeing CORS errors during refresh.

Redirect URI mismatch: The redirect URI registered in Keycloak must exactly match what Auth.js sends. For local development, it should be http://localhost:3000/api/auth/callback/keycloak.

Security Considerations

A few things to keep in mind for production deployments:

  • Always use HTTPS in production. Keycloak should be behind TLS, and your Next.js app should be served over HTTPS.
  • Rotate your AUTH_SECRET periodically. This secret is used to encrypt session cookies.
  • Set appropriate token lifetimes in Keycloak. Short-lived access tokens (5-15 minutes) with refresh tokens are the recommended pattern.
  • Enable audit logging in Keycloak to track authentication events. This is critical for compliance and incident response — see our guide on auditing in Keycloak.
  • Consider MFA for sensitive operations. Keycloak supports step-up authentication where you can require a second factor for specific actions.

What’s Next

With authentication in place, you can extend your setup by:

For the complete Keycloak documentation on OIDC clients, see the Keycloak Server Administration Guide.


Ready to skip the Keycloak infrastructure work? Skycloak gives you a fully managed Keycloak instance with built-in high availability, automated upgrades, and enterprise-grade security. See our pricing to get started.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman