Skip to main content

Securing React Router v7 (formerly Remix) Apps with Keycloak

· 2 min read
Rishi Raj Jain
Guest contributor

In this article we'll be using Keycloak to quickly secure a React Router v7 application, the successor of Remix, 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 React Router 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 React Router Project​

info

We will use the Phase Two React Router example code here, but the logic could easily be applied to any existing application. Remix v2 is now React Router v7, and the example is the Remix example migrated to React Router's framework mode.

  1. Clone the Phase Two example repo.

  2. Open the React Router folder within /frameworks/remix. The folder keeps its Remix name.

  3. Copy .env.example to .env:

    cp .env.example .env

    Its values point at the local Keycloak from the example repo, which docker compose -f keycloak/docker-compose.yml up -d --wait starts from the repository root. That Keycloak's p2examples realm already has a confidential client with the ID remix. In .env.example:

    KEYCLOAK_ISSUER=http://localhost:8080/auth/realms/p2examples
    KEYCLOAK_CLIENT_ID=remix
    KEYCLOAK_CLIENT_SECRET=remix-local-dev-secret
    KEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/keycloak/callback
    SESSION_SECRET=

    Set SESSION_SECRET to a random value, which signs the session cookie. openssl rand -base64 32 generates one.

    To use another Keycloak, set KEYCLOAK_ISSUER to your realm's issuer URL, and KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET to the ID and secret of your client. The client needs client authentication on, the standard flow enabled, http://localhost:3000/* as a valid redirect URI, which covers KEYCLOAK_CALLBACK_URL, and + as a valid post logout redirect URI. .env is ignored by git, so the secrets stay out of version control.

  4. Run pnpm install and then pnpm dev, with Node 24. The app runs on port 3000. This example leverages remix-auth and its OAuth2 strategy to log users in with the authorization code flow and PKCE.

  5. Open the app/auth.server.ts file. This is a server only file. It sets up the remix-auth Authenticator with an OAuth2Strategy, using the environment variables from .env. OAuth2Strategy.discover reads Keycloak's endpoints from the realm's OpenID configuration, so the issuer URL is the only Keycloak URL the app needs:

    import { Authenticator } from "remix-auth";
    import { OAuth2Strategy } from "remix-auth-oauth2";
    import { requireEnv } from "./env.server";
    import { decodeJwtPayload } from "./jwt.server";

    export type SessionUser = {
    idToken: string;
    accessTokenClaims: Record<string, unknown>;
    };

    async function createAuthenticator() {
    const authenticator = new Authenticator<SessionUser>();
    const strategy = await OAuth2Strategy.discover<SessionUser>(
    requireEnv("KEYCLOAK_ISSUER"),
    {
    clientId: requireEnv("KEYCLOAK_CLIENT_ID"),
    clientSecret: requireEnv("KEYCLOAK_CLIENT_SECRET"),
    redirectURI: requireEnv("KEYCLOAK_CALLBACK_URL"),
    scopes: ["openid", "profile", "email"],
    },
    async ({ tokens }) => ({
    idToken: tokens.idToken(),
    accessTokenClaims: decodeJwtPayload(tokens.accessToken()),
    }),
    );
    authenticator.use(strategy, "keycloak");
    return authenticator;
    }

    After a login, the verify function at the end keeps the ID token and the decoded claims of the access token. getAuthenticator(), further down in the file, creates the authenticator on first use, so builds don't need the environment variables or Keycloak.

    remix-auth 4 no longer manages sessions, so the app keeps its own. app/sessions.server.ts defines a signed, HTTP-only cookie session keyed by SESSION_SECRET:

    import { createCookieSessionStorage } from "react-router";
    import { requireEnv } from "./env.server";

    type SessionData = {
    idToken: string;
    accessTokenClaims: Record<string, unknown>;
    };

    export const sessionStorage = createCookieSessionStorage<SessionData>({
    cookie: {
    name: "__session",
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    secure: process.env.NODE_ENV === "production",
    secrets: [requireEnv("SESSION_SECRET")],
    maxAge: 60 * 60 * 8,
    },
    });

    export function getSession(request: Request) {
    return sessionStorage.getSession(request.headers.get("Cookie"));
    }
  6. app/routes.ts lists the routes. The Log in button posts to /auth/keycloak, whose action calls authenticator.authenticate("keycloak", request) to redirect you to Keycloak. Keycloak sends you back to /auth/keycloak/callback, which completes the login and stores the session. In app/routes/auth.keycloak.callback.tsx:

    import { redirect } from "react-router";
    import { getAuthenticator } from "~/auth.server";
    import { getSession, sessionStorage } from "~/sessions.server";
    import type { Route } from "./+types/auth.keycloak.callback";

    export async function loader({ request }: Route.LoaderArgs) {
    const authenticator = await getAuthenticator();
    const user = await authenticator.authenticate("keycloak", request);

    const session = await getSession(request);
    session.set("idToken", user.idToken);
    session.set("accessTokenClaims", user.accessTokenClaims);

    return redirect("/", {
    headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
    });
    }

    The loader in app/routes/home.tsx reads the session on the server and returns only decoded claims, so the page's JavaScript never sees a token:

    export async function loader({ request }: Route.LoaderArgs) {
    const session = await getSession(request);
    const idToken = session.get("idToken");

    if (!idToken) {
    return { user: null };
    }

    return {
    user: {
    idTokenClaims: decodeJwtPayload(idToken),
    accessTokenClaims: session.get("accessTokenClaims") ?? {},
    },
    };
    }

    View app/components/user-status.tsx for how the page renders your state. It shows the Log in button when the loader returns no user, and otherwise your name and email, the Log out button and the decoded tokens. Both buttons are forms that post to the server. The same session check works in any route's loader, so you can use it to secure routes, components, and more.

  7. The Log out button posts to /logout. Its action in app/routes/logout.tsx clears the session and redirects to Keycloak's end-session endpoint, with the ID token as id_token_hint, so your Keycloak session ends too:

    import { redirect } from "react-router";
    import { getEndSessionEndpoint } from "~/auth.server";
    import { requireEnv } from "~/env.server";
    import { getSession, sessionStorage } from "~/sessions.server";
    import type { Route } from "./+types/logout";

    export async function action({ request }: Route.ActionArgs) {
    const session = await getSession(request);
    const logoutUrl = new URL(await getEndSessionEndpoint());
    logoutUrl.searchParams.set("client_id", requireEnv("KEYCLOAK_CLIENT_ID"));
    logoutUrl.searchParams.set(
    "post_logout_redirect_uri",
    new URL("/", request.url).href,
    );

    const idToken = session.get("idToken");
    if (idToken) {
    logoutUrl.searchParams.set("id_token_hint", idToken);
    }

    return redirect(logoutUrl.href, {
    headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
    });
    }

    getEndSessionEndpoint() in app/auth.server.ts reads the endpoint from the realm's OpenID configuration.

  8. 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 your Keycloak login page.

    info

    Use demo / demo on the local Keycloak, or a non-admin user of your realm, to sign in.

  9. Enter the user's credentials. Click Sign In. You will then be redirected to the application. The Phase Two example landing page now shows your Authenticated state, with your user's name and email and the decoded access and ID tokens.

  10. Click Log out. Keycloak ends your session and sends you back to the application, which shows Not authenticated. again. The next Log in 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.