Remix Authentication with Keycloak: Full-Stack Auth

Guilliano Molaire Guilliano Molaire Updated June 6, 2026 9 min read

Last updated: March 2026

Remix takes a different approach to web development than most React frameworks. It leans heavily on server-side rendering, uses loaders and actions for data flow, and treats the browser as a progressive enhancement rather than the primary execution environment. This philosophy aligns well with how authentication should work: sessions managed server-side, tokens never exposed to the client, and authentication state available in every loader.

This guide walks through integrating Keycloak with a Remix application using remix-auth and the OAuth2 strategy. You will set up cookie-based sessions, server-side authentication in loaders and actions, protected routes, and role-based access control — all following Remix conventions.

Prerequisites

Step 1: Set Up a Keycloak Client

In the Keycloak Admin Console:

  1. Go to Clients > Create client
  2. Set Client ID to remix-app
  3. Set Client type to OpenID Connect
  4. Enable Client authentication (confidential client)
  5. Under Valid redirect URIs, add http://localhost:5173/auth/keycloak/callback
  6. Under Valid post logout redirect URIs, add http://localhost:5173
  7. Under Web origins, add http://localhost:5173

After saving, go to the Credentials tab and copy the Client secret.

Configure Roles

Create client roles for authorization:

  1. Go to the client’s Roles tab
  2. Create admin and user roles
  3. Assign roles to test users under Users > Role mappings > Client roles

See RBAC in Keycloak for more on configuring role-based access control.

Step 2: Create the Remix Project

npx create-remix@latest keycloak-remix-app
cd keycloak-remix-app

Install authentication dependencies:

npm install remix-auth remix-auth-oauth2

Create your .env file:

# .env
SESSION_SECRET=a-very-long-random-string-at-least-32-characters
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=myrealm
KEYCLOAK_CLIENT_ID=remix-app
KEYCLOAK_CLIENT_SECRET=your-client-secret-from-keycloak
APP_URL=http://localhost:5173

Step 3: Session Configuration

Remix uses a session storage API for managing cookies. Create a cookie-based session store:

// app/services/session.server.ts
import { createCookieSessionStorage } from "@remix-run/node";

if (!process.env.SESSION_SECRET) {
  throw new Error("SESSION_SECRET must be set");
}

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

export const { getSession, commitSession, destroySession } =
  sessionStorage;

Step 4: Keycloak Configuration

// app/services/keycloak.server.ts
export const keycloakConfig = {
  baseUrl: process.env.KEYCLOAK_URL || "http://localhost:8080",
  realm: process.env.KEYCLOAK_REALM || "myrealm",
  clientId: process.env.KEYCLOAK_CLIENT_ID || "remix-app",
  clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
  appUrl: process.env.APP_URL || "http://localhost:5173",

  get realmUrl() {
    return `${this.baseUrl}/realms/${this.realm}`;
  },

  get authorizationEndpoint() {
    return `${this.realmUrl}/protocol/openid-connect/auth`;
  },

  get tokenEndpoint() {
    return `${this.realmUrl}/protocol/openid-connect/token`;
  },

  get userInfoEndpoint() {
    return `${this.realmUrl}/protocol/openid-connect/userinfo`;
  },

  get endSessionEndpoint() {
    return `${this.realmUrl}/protocol/openid-connect/logout`;
  },
};

Step 5: Remix Auth Configuration

Set up remix-auth with the OAuth2 strategy configured for Keycloak:

// app/services/auth.server.ts
import { Authenticator } from "remix-auth";
import { OAuth2Strategy } from "remix-auth-oauth2";
import { sessionStorage } from "./session.server";
import { keycloakConfig } from "./keycloak.server";

export interface UserSession {
  id: string;
  email: string;
  name: string;
  username: string;
  emailVerified: boolean;
  roles: string[];
  clientRoles: string[];
  accessToken: string;
  refreshToken: string;
  idToken: string;
  expiresAt: number;
}

export const authenticator = new Authenticator<UserSession>(
  sessionStorage
);

const keycloakStrategy = new OAuth2Strategy(
  {
    clientId: keycloakConfig.clientId,
    clientSecret: keycloakConfig.clientSecret,
    authorizationEndpoint: keycloakConfig.authorizationEndpoint,
    tokenEndpoint: keycloakConfig.tokenEndpoint,
    redirectURI: `${keycloakConfig.appUrl}/auth/keycloak/callback`,
    scopes: ["openid", "profile", "email", "offline_access"],
    tokenRevocationEndpoint: `${keycloakConfig.realmUrl}/protocol/openid-connect/revoke`,
  },
  async ({ tokens, request }) => {
    // Fetch user info from Keycloak
    const userInfoResponse = await fetch(
      keycloakConfig.userInfoEndpoint,
      {
        headers: {
          Authorization: `Bearer ${tokens.accessToken()}`,
        },
      }
    );

    if (!userInfoResponse.ok) {
      throw new Error("Failed to fetch user info");
    }

    const userInfo = await userInfoResponse.json();

    // Decode the access token to extract roles
    const tokenPayload = JSON.parse(
      Buffer.from(
        tokens.accessToken().split(".")[1],
        "base64"
      ).toString()
    );

    return {
      id: userInfo.sub,
      email: userInfo.email,
      name: userInfo.name || userInfo.preferred_username,
      username: userInfo.preferred_username,
      emailVerified: userInfo.email_verified,
      roles: tokenPayload.realm_access?.roles || [],
      clientRoles:
        tokenPayload.resource_access?.["remix-app"]
          ?.roles || [],
      accessToken: tokens.accessToken(),
      refreshToken: tokens.hasRefreshToken()
        ? tokens.refreshToken()
        : "",
      idToken: tokens.idToken(),
      expiresAt: tokenPayload.exp,
    } satisfies UserSession;
  }
);

authenticator.use(keycloakStrategy, "keycloak");

Step 6: Auth Routes

Create the routes that handle the OAuth callback flow.

Login Route

// app/routes/auth.keycloak.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";

export async function loader() {
  return redirect("/login");
}

export async function action({ request }: ActionFunctionArgs) {
  return authenticator.authenticate("keycloak", request);
}

Callback Route

// app/routes/auth.keycloak.callback.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  return authenticator.authenticate("keycloak", request, {
    successRedirect: "/dashboard",
    failureRedirect: "/login?error=auth_failed",
  });
}

Logout Route

// app/routes/auth.logout.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
import {
  getSession,
  destroySession,
} from "~/services/session.server";
import { keycloakConfig } from "~/services/keycloak.server";

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const user = session.get(authenticator.sessionKey);

  // Build Keycloak logout URL
  const logoutUrl = new URL(keycloakConfig.endSessionEndpoint);
  if (user?.idToken) {
    logoutUrl.searchParams.set("id_token_hint", user.idToken);
  }
  logoutUrl.searchParams.set(
    "post_logout_redirect_uri",
    keycloakConfig.appUrl
  );

  // Destroy the local session and redirect to Keycloak logout
  return redirect(logoutUrl.toString(), {
    headers: {
      "Set-Cookie": await destroySession(session),
    },
  });
}

Step 7: Helper Functions

Create utility functions for common auth patterns in loaders and actions:

// app/services/auth-helpers.server.ts
import { redirect } from "@remix-run/node";
import { authenticator, UserSession } from "./auth.server";

/**
 * Require authentication in a loader/action.
 * Redirects to /login if not authenticated.
 */
export async function requireAuth(
  request: Request
): Promise<UserSession> {
  const user = await authenticator.isAuthenticated(request);

  if (!user) {
    const url = new URL(request.url);
    throw redirect(`/login?returnTo=${url.pathname}`);
  }

  return user;
}

/**
 * Require a specific role.
 * Redirects to /unauthorized if the role is missing.
 */
export async function requireRole(
  request: Request,
  role: string
): Promise<UserSession> {
  const user = await requireAuth(request);

  if (!user.clientRoles.includes(role)) {
    throw redirect("/unauthorized");
  }

  return user;
}

/**
 * Get the current user if authenticated, or null.
 * Does not redirect --- useful for optional auth.
 */
export async function getOptionalUser(
  request: Request
): Promise<UserSession | null> {
  return authenticator.isAuthenticated(request);
}

Step 8: Pages

Login Page

// app/routes/login.tsx
import type {
  LoaderFunctionArgs,
  MetaFunction,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { authenticator } from "~/services/auth.server";

export const meta: MetaFunction = () => {
  return [{ title: "Sign In" }];
};

export async function loader({ request }: LoaderFunctionArgs) {
  // Redirect to dashboard if already authenticated
  await authenticator.isAuthenticated(request, {
    successRedirect: "/dashboard",
  });

  return json({});
}

export default function LoginPage() {
  const [searchParams] = useSearchParams();
  const error = searchParams.get("error");
  const returnTo = searchParams.get("returnTo");

  return (
    <div className="login-container">
      <div className="login-card">
        <h1>Welcome Back</h1>
        <p>Sign in to access your account</p>

        {error && (
          <div className="error-message">
            Authentication failed. Please try again.
          </div>
        )}

        <Form method="post" action="/auth/keycloak">
          {returnTo && (
            <input
              type="hidden"
              name="returnTo"
              value={returnTo}
            />
          )}
          <button type="submit" className="login-button">
            Sign in with Keycloak
          </button>
        </Form>
      </div>
    </div>
  );
}

Dashboard (Protected)

// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
import { requireAuth } from "~/services/auth-helpers.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireAuth(request);

  return json({
    user: {
      name: user.name,
      email: user.email,
      username: user.username,
      roles: user.roles,
      clientRoles: user.clientRoles,
    },
  });
}

export default function DashboardPage() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div className="dashboard">
      <header className="dashboard-header">
        <h1>Dashboard</h1>
        <Form method="post" action="/auth/logout">
          <button type="submit" className="logout-button">
            Sign out
          </button>
        </Form>
      </header>

      <div className="user-info">
        <h2>Welcome, {user.name}</h2>
        <dl>
          <dt>Email</dt>
          <dd>{user.email}</dd>
          <dt>Username</dt>
          <dd>{user.username}</dd>
          <dt>Realm Roles</dt>
          <dd>{user.roles.join(", ")}</dd>
          <dt>Client Roles</dt>
          <dd>
            {user.clientRoles.length > 0
              ? user.clientRoles.join(", ")
              : "None"}
          </dd>
        </dl>
      </div>
    </div>
  );
}

Admin Page (Role-Protected)

// app/routes/admin.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireRole } from "~/services/auth-helpers.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireRole(request, "admin");

  // Fetch admin-only data using the access token
  // This demonstrates forwarding the token to backend services
  const adminData = await fetchAdminData(user.accessToken);

  return json({ user, adminData });
}

async function fetchAdminData(accessToken: string) {
  // Example: call a protected backend API
  // const response = await fetch("https://api.example.com/admin/stats", {
  //   headers: { Authorization: `Bearer ${accessToken}` },
  // });
  // return response.json();

  return {
    totalUsers: 1234,
    activeToday: 567,
    pendingApprovals: 12,
  };
}

export default function AdminPage() {
  const { adminData } = useLoaderData<typeof loader>();

  return (
    <div className="admin-panel">
      <h1>Admin Panel</h1>
      <div className="stats-grid">
        <div className="stat-card">
          <span className="stat-value">
            {adminData.totalUsers}
          </span>
          <span className="stat-label">Total Users</span>
        </div>
        <div className="stat-card">
          <span className="stat-value">
            {adminData.activeToday}
          </span>
          <span className="stat-label">Active Today</span>
        </div>
        <div className="stat-card">
          <span className="stat-value">
            {adminData.pendingApprovals}
          </span>
          <span className="stat-label">
            Pending Approvals
          </span>
        </div>
      </div>
    </div>
  );
}

Step 9: Token Refresh in Loaders

Add automatic token refresh logic to your auth helper:

// app/services/token-refresh.server.ts
import {
  getSession,
  commitSession,
} from "./session.server";
import { authenticator, UserSession } from "./auth.server";
import { keycloakConfig } from "./keycloak.server";

export async function refreshTokenIfNeeded(
  request: Request
): Promise<{
  user: UserSession;
  headers?: HeadersInit;
} | null> {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const user = session.get(
    authenticator.sessionKey
  ) as UserSession | null;

  if (!user) return null;

  const now = Math.floor(Date.now() / 1000);

  // If token is still valid (with 60s buffer), return as-is
  if (now < user.expiresAt - 60) {
    return { user };
  }

  // Token expired or expiring soon --- refresh
  if (!user.refreshToken) return null;

  try {
    const tokenResponse = await fetch(
      keycloakConfig.tokenEndpoint,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "refresh_token",
          client_id: keycloakConfig.clientId,
          client_secret: keycloakConfig.clientSecret,
          refresh_token: user.refreshToken,
        }),
      }
    );

    if (!tokenResponse.ok) {
      return null; // Refresh failed
    }

    const tokens = await tokenResponse.json();
    const payload = JSON.parse(
      Buffer.from(
        tokens.access_token.split(".")[1],
        "base64"
      ).toString()
    );

    const updatedUser: UserSession = {
      ...user,
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token || user.refreshToken,
      idToken: tokens.id_token || user.idToken,
      expiresAt: payload.exp,
    };

    session.set(authenticator.sessionKey, updatedUser);

    return {
      user: updatedUser,
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    };
  } catch {
    return null;
  }
}

For more on token lifecycle patterns, see JWT token lifecycle management.

Security Considerations

Cookie Configuration

The session cookie configuration in Step 3 follows security best practices:

  • httpOnly: true — prevents JavaScript access to the cookie (XSS mitigation)
  • sameSite: "lax" — protects against CSRF while allowing navigation from external links
  • secure: true in production — cookies only sent over HTTPS

Token Storage

Tokens are stored in the encrypted session cookie on the server side. The client never sees the raw access token. This is inherently more secure than storing tokens in localStorage or client-side state, which is a common pattern in SPA frameworks.

For more on session management approaches, see the guide on session management in distributed systems and Keycloak’s session management features.

CORS

If your Remix app calls Keycloak APIs directly from the server, CORS is not a concern (server-to-server). If you need client-side Keycloak API calls, see configuring CORS with Keycloak.

Debugging

During development, inspect the tokens in your session using our JWT Token Analyzer. Add a debug route (remove in production):

// app/routes/debug.auth.tsx (development only)
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getOptionalUser } from "~/services/auth-helpers.server";

export async function loader({ request }: LoaderFunctionArgs) {
  if (process.env.NODE_ENV !== "development") {
    throw new Response("Not Found", { status: 404 });
  }

  const user = await getOptionalUser(request);

  return json({
    authenticated: !!user,
    user: user
      ? {
          id: user.id,
          email: user.email,
          roles: user.roles,
          clientRoles: user.clientRoles,
          expiresAt: new Date(
            user.expiresAt * 1000
          ).toISOString(),
        }
      : null,
  });
}

If you are using SAML identity providers with Keycloak brokering, our SAML Decoder can help debug assertion payloads.

Further Reading

Wrapping Up

Remix and Keycloak are a natural fit. Remix’s server-first architecture keeps authentication tokens on the server where they belong, and remix-auth provides the plumbing to integrate any OAuth provider with minimal boilerplate. The loader/action pattern makes it straightforward to check auth on every request without client-side complexity.

If you want Keycloak without the operational overhead, Skycloak provides fully managed instances with security monitoring, guaranteed uptime, and audit logging. Check our pricing page to find the right plan for your application.

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