Skip to main content

Securing Next.js Apps with Keycloak

· 7 min read
Jeff Patzer
Phase Two

In this article we'll be using Keycloak to quickly secure a Next.js 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.

info

If you just want to skip to the code, visit the Phase Two Next.js example. We also have a plain React example.

Setting up a Keycloak Instance​

Instructions
tip

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
WhatValue
Issuer URLhttp://localhost:8080/auth/realms/p2examples
Admin consolelocalhost:8080/auth/admin, admin / admin
Demo userdemo / 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​

  1. Sign up on the Phase Two Dashboard with an email address, a GitHub account or a Google account.
  2. Create a Starter cluster, which is free for 30 days, and add a realm to it.
  3. 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 logs users in from its server, so it gets a confidential client: the server authenticates to Keycloak with a client secret that never reaches the browser. Keycloak's docs describe all the client settings.

  1. Open the Keycloak admin console and select your realm.

  2. Click Clients in the menu.

  3. Click Create client.

  4. Leave Client type set to OpenID Connect.

  5. Enter a Client ID. The app sends this ID in its OpenID Connect requests.

  6. Supply a Name for the client.

  7. Click Next.

    Keycloak OIDC Create Client General Settings

  8. Under Capability config:

    • Turn Client authentication on.
    • 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.

    Keycloak OIDC Create Client Capability Config with Authentication

    Click Next.

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

    +

    The app's server calls Keycloak itself, so Web origins can stay empty.

    URI Details

    Use the port of the app you run: most examples run on port 3000, and the Django example on 8000. For an app deployed somewhere, use its URL instead of localhost.

  10. Click Save.

    Keycloak OIDC Create Client Login Settings

  11. Open the Credentials tab and copy the Client Secret. Keep it for later in this tutorial, and out of your source code and version control.

    Keycloak OIDC Client Secret

OIDC Config​

The app needs three values from Keycloak:

  • Issuer URL: the realm's URL, such as http://localhost:8080/auth/realms/p2examples for the local Keycloak, or https://<your-keycloak-host>/auth/realms/<your-realm>. Realm settings > General > Endpoints > OpenID Endpoint Configuration shows it as issuer.
  • Client ID: the ID you entered when you created the client.
  • Client secret: the secret you copied from the Credentials tab.

Adding a Non-Admin User​

Instructions
tip

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:

  1. Open the Keycloak admin console and select your realm.
  2. Click Users in the menu.
  3. Click Add user.
  4. Fill out the Username, Email, First name and Last name. Click Create.
  5. Open the Credentials tab and click Set password. Enter a password for the user. For this tutorial, you can turn Temporary off.
  6. Click Save, then confirm with Save password.

Setting up a Next.js Project​

info

We will use the Phase Two Next.js example code here, but the logic could easily be applied to any existing application.

The example is a Next.js 16 app that uses the App Router. It logs users in with NextAuth.js 4.24 and its Keycloak provider. The login runs on the server: the tokens stay in an encrypted, HTTP-only session cookie, and the browser only sees their decoded claims. You need Node.js 24 and pnpm.

  1. Clone the Phase Two example repo if you haven't yet, and open the Next.js folder:

    git clone https://github.com/p2-inc/examples.git
    cd examples/frameworks/nextjs
  2. Copy .env.example to .env:

    cp .env.example .env

    The app reads these variables. .env.example already holds the values for the local Keycloak, including its nextjs client:

    VariableDescriptionLocal Keycloak
    NEXTAUTH_URLPublic URL of the apphttp://localhost:3000
    NEXTAUTH_SECRETRandom secret that encrypts the session cookieGenerate your own
    KEYCLOAK_IDClient IDnextjs
    KEYCLOAK_SECRETClient secretnextjs-local-dev-secret
    KEYCLOAK_ISSUERIssuer URL of the realmhttp://localhost:8080/auth/realms/p2examples

    Set NEXTAUTH_SECRET in .env to a random value, such as the output of openssl rand -base64 32. It is not the Keycloak client secret.

    To use another Keycloak, set KEYCLOAK_ISSUER, KEYCLOAK_ID and KEYCLOAK_SECRET to the issuer URL, client ID and client secret from Setting up an OIDC Client. Use the client ID you entered there, since nextjs is only the client of the local Keycloak. .env is ignored by git, so your secrets stay out of version control.

  3. Install the dependencies and start the app:

    pnpm install
    pnpm dev
  4. Open src/auth.ts. This server-only file reads the Keycloak settings from the environment. Its refreshAccessToken function trades the refresh token for new tokens at Keycloak's token endpoint:

    import type { NextAuthOptions } from "next-auth";
    import type { JWT } from "next-auth/jwt";
    import KeycloakProvider from "next-auth/providers/keycloak";
    import { decodeJwtPayload } from "@/lib/jwt";

    const issuer = process.env.KEYCLOAK_ISSUER ?? "";
    const clientId = process.env.KEYCLOAK_ID ?? "";
    const clientSecret = process.env.KEYCLOAK_SECRET ?? "";

    async function refreshAccessToken(token: JWT): Promise<JWT> {
    if (!token.refreshToken) {
    return { ...token, error: "RefreshAccessTokenError" };
    }

    const response = await fetch(`${issuer}/protocol/openid-connect/token`, {
    method: "POST",
    body: new URLSearchParams({
    grant_type: "refresh_token",
    client_id: clientId,
    client_secret: clientSecret,
    refresh_token: token.refreshToken,
    }),
    });

    if (!response.ok) {
    return { ...token, error: "RefreshAccessTokenError" };
    }

    const tokens = await response.json();
    return {
    ...token,
    accessToken: tokens.access_token,
    idToken: tokens.id_token ?? token.idToken,
    refreshToken: tokens.refresh_token ?? token.refreshToken,
    expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
    error: undefined,
    };
    }

    Further down in src/auth.ts, authOptions configures NextAuth.js with the Keycloak provider. The route handler and the page both use it:

    export const authOptions: NextAuthOptions = {
    providers: [KeycloakProvider({ clientId, clientSecret, issuer })],
    session: { strategy: "jwt" },
    callbacks: {
    async jwt({ token, account }) {
    if (account) {
    return {
    ...token,
    accessToken: account.access_token,
    idToken: account.id_token,
    refreshToken: account.refresh_token,
    expiresAt: account.expires_at,
    };
    }

    if (token.expiresAt && Date.now() < (token.expiresAt - 30) * 1000) {
    return token;
    }

    return refreshAccessToken(token);
    },
    async session({ session, token }) {
    return {
    ...session,
    error: token.error,
    accessTokenClaims: decodeJwtPayload(token.accessToken),
    idTokenClaims: decodeJwtPayload(token.idToken),
    };
    },
    },
    events: {
    async signOut({ token }) {
    if (!token.refreshToken) {
    return;
    }

    await fetch(`${issuer}/protocol/openid-connect/logout`, {
    method: "POST",
    body: new URLSearchParams({
    client_id: clientId,
    client_secret: clientSecret,
    refresh_token: token.refreshToken,
    }),
    });
    },
    },
    };
    • session: { strategy: "jwt" } keeps the session in the encrypted cookie, so the app needs no database.
    • The jwt callback stores Keycloak's access, ID and refresh tokens in that cookie when the user logs in. When the access token has less than 30 seconds left, it calls refreshAccessToken.
    • The session callback decides what the app gets from the session: the decoded claims of the access and ID tokens, and an error if the refresh failed. The tokens themselves never leave the server.
    • The signOut event posts the refresh token to Keycloak's logout endpoint, so logging out of the app also ends the Keycloak session.

    decodeJwtPayload is in src/lib/jwt.ts, and src/types/next-auth.d.ts adds these fields to the JWT and Session types.

  5. NextAuth.js serves its own routes under /api/auth, such as the callback that Keycloak redirects to after the login. src/app/api/auth/[...nextauth]/route.ts mounts them with authOptions:

    import NextAuth from "next-auth";
    import { authOptions } from "@/auth";

    const handler = NextAuth(authOptions);

    export { handler as GET, handler as POST };
  6. src/app/page.tsx is a Server Component. It reads the session on the server with getServerSession(authOptions) and passes it to the User component from src/components/user.component.tsx. User shows "Not authenticated." and a Log in button, or your name and email, the decoded tokens and a Log out button. If the token refresh failed, it asks you to log in again.

    The buttons call signIn and signOut from next-auth/react. They are in src/components/buttons.components.tsx, the app's only Client Component:

    "use client";

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

    const buttonClasses =
    "cursor-pointer rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600";

    export function LoginButton() {
    return (
    <button className={buttonClasses} onClick={() => signIn("keycloak")}>
    Log in
    </button>
    );
    }

    export function LogoutButton() {
    return (
    <button
    className={buttonClasses}
    onClick={() => signOut({ callbackUrl: "/" })}
    >
    Log out
    </button>
    );
    }

    Protect other Server Components, route handlers and server actions the same way: call getServerSession(authOptions) and check that it returns a session.

  7. Open localhost:3000. You will see the Phase Two example landing page. Your current status should be "Not authenticated." Click Log in. This will redirect you to the Keycloak login page.

    info

    Use the non-admin user created in the previous section to sign in.

  8. Enter the credentials of the non-admin user and sign in. You will then be redirected to the application. The Phase Two example landing page now shows your "Authenticated" state, your user's name and email, and the decoded access token and ID token.

  9. Click Log out. The app also ends your Keycloak session, so the next time you click Log in, Keycloak asks for your credentials again.

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.