Remix Authentication with Keycloak: Full-Stack Auth
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
- Node.js 18+ and npm 10+
- Remix v2 (using Vite)
- A running Keycloak instance (version 22+). Use our Docker Compose Generator for quick setup or try managed Keycloak hosting.
Step 1: Set Up a Keycloak Client
In the Keycloak Admin Console:
- Go to Clients > Create client
- Set Client ID to
remix-app - Set Client type to
OpenID Connect - Enable Client authentication (confidential client)
- Under Valid redirect URIs, add
http://localhost:5173/auth/keycloak/callback - Under Valid post logout redirect URIs, add
http://localhost:5173 - 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:
- Go to the client’s Roles tab
- Create
adminanduserroles - 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 linkssecure: truein 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
- Keycloak Securing Applications Guide
- Remix Auth Documentation
- Next.js authentication with Keycloak — similar server-side patterns in Next.js
- SvelteKit authentication with Keycloak — another full-stack framework
- BFF pattern with Keycloak — the architectural pattern behind server-side auth
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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.