Last updated: March 2026
HTMX is an anti-SPA. Instead of shipping a JavaScript framework to the browser and managing authentication tokens in localStorage, HTMX lets you build dynamic interfaces with server-rendered HTML fragments. The server manages all state, including authentication. This means no JWTs in the browser, no token refresh logic in JavaScript, and no CORS headaches — just HTTP-only session cookies that the browser handles automatically.
This architecture is a natural fit for Keycloak. The server authenticates with Keycloak via OIDC, stores the session in a secure cookie, and every HTMX request carries that cookie automatically. This guide walks through building an HTMX application with Keycloak authentication using Express.js as the backend.
Prerequisites
- Node.js 18+ and npm 10+
- A running Keycloak instance (version 22+). Use our Docker Compose Generator for a quick local setup or try managed Keycloak hosting.
- Basic familiarity with Express.js and HTML
Why HTMX + Server-Side Auth
Before diving into code, it is worth understanding why this pattern is appealing from a security perspective:
- No tokens in the browser — access tokens stay on the server. XSS attacks cannot steal them.
- Cookie-based sessions — the browser manages cookie sending automatically. No auth header management needed.
- Server-side validation on every request — every HTMX request hits the server, where session validation happens before any HTML is returned.
- Simpler security model — no token refresh logic, no token storage decisions, no CORS token forwarding.
This is the same architecture that traditional server-rendered frameworks (Rails, Django, Laravel) have used for decades. HTMX brings the dynamic UI capabilities of SPAs to this model without the complexity of client-side authentication.
Step 1: Set Up a Keycloak Client
In the Keycloak Admin Console:
- Go to Clients > Create client
- Set Client ID to
htmx-app - Set Client type to
OpenID Connect - Enable Client authentication (confidential client)
- Under Valid redirect URIs, add
http://localhost:3000/auth/callback - Under Valid post logout redirect URIs, add
http://localhost:3000 - Under Web origins, add
http://localhost:3000
After saving, copy the Client secret from the Credentials tab.
Create test roles under the client’s Roles tab — admin and user — and assign them to test users. For more on role-based access, see our RBAC feature overview.
Step 2: Project Setup
mkdir keycloak-htmx-app
cd keycloak-htmx-app
npm init -y
Install dependencies:
npm install express express-session openid-client csrf-csrf cookie-parser ejs
{
"dependencies": {
"cookie-parser": "^1.4.7",
"csrf-csrf": "^3.0.6",
"ejs": "^3.1.10",
"express": "^4.21.0",
"express-session": "^1.18.1",
"openid-client": "^6.1.0"
}
}
Each package serves a purpose:
openid-client— OIDC client library that handles discovery, token exchange, and user infoexpress-session— server-side session management with cookie IDscsrf-csrf— CSRF protection for HTMX requests (important since HTMX sends POST/PUT/DELETE)ejs— minimal templating for server-rendered HTML
Step 3: OIDC Configuration
// src/auth.js
import { discovery } from "openid-client";
let oidcConfig = null;
export async function initializeOIDC() {
const issuerUrl = new URL(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`
);
oidcConfig = await discovery(issuerUrl, process.env.KEYCLOAK_CLIENT_ID, {
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
});
return oidcConfig;
}
export function getOIDCConfig() {
if (!oidcConfig) {
throw new Error("OIDC not initialized. Call initializeOIDC() first.");
}
return oidcConfig;
}
Step 4: Authentication Middleware
Create middleware that checks session state and protects routes:
// src/middleware.js
/**
* Middleware that requires authentication.
* For HTMX requests, returns a 401 with an HX-Redirect header.
* For regular requests, redirects to the login page.
*/
export function requireAuth(req, res, next) {
if (req.session?.user) {
// Check if the access token has expired
const now = Math.floor(Date.now() / 1000);
if (req.session.tokenExpiry && now >= req.session.tokenExpiry) {
// Token expired --- attempt refresh or redirect to login
return handleExpiredSession(req, res);
}
return next();
}
// Store the original URL for post-login redirect
req.session.returnTo = req.originalUrl;
if (req.headers["hx-request"]) {
// HTMX request --- tell the client to redirect
res.set("HX-Redirect", "/login");
return res.status(401).send("");
}
return res.redirect("/login");
}
/**
* Middleware that requires a specific role.
*/
export function requireRole(role) {
return (req, res, next) => {
if (!req.session?.user) {
return requireAuth(req, res, next);
}
const roles = req.session.user.clientRoles || [];
if (!roles.includes(role)) {
if (req.headers["hx-request"]) {
return res.status(403).send(`
<div class="error-message">
You do not have permission to access this resource.
</div>
`);
}
return res.status(403).render("unauthorized");
}
next();
};
}
/**
* Make user data available to all templates.
*/
export function userContext(req, res, next) {
res.locals.user = req.session?.user || null;
res.locals.isAuthenticated = !!req.session?.user;
next();
}
function handleExpiredSession(req, res) {
req.session.destroy(() => {
if (req.headers["hx-request"]) {
res.set("HX-Redirect", "/login");
return res.status(401).send("");
}
return res.redirect("/login");
});
}
The HX-Redirect header is key. When an HTMX request receives this header, it triggers a full page navigation to the specified URL. This lets you handle authentication redirects cleanly without breaking the HTMX partial-update model.
Step 5: Express Application
// src/app.js
import express from "express";
import session from "express-session";
import cookieParser from "cookie-parser";
import { doubleCsrf } from "csrf-csrf";
import path from "path";
import { fileURLToPath } from "url";
import { initializeOIDC, getOIDCConfig } from "./auth.js";
import {
requireAuth,
requireRole,
userContext,
} from "./middleware.js";
import * as openidClient from "openid-client";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
// View engine
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
// Middleware
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(process.env.SESSION_SECRET));
app.use(
session({
secret: process.env.SESSION_SECRET || "change-me",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
})
);
// CSRF protection
const { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: () => process.env.SESSION_SECRET || "change-me",
cookieName: "__csrf",
cookieOptions: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
},
getTokenFromRequest: (req) =>
req.headers["x-csrf-token"] || req.body?._csrf,
});
// Make CSRF token available to templates
app.use((req, res, next) => {
res.locals.csrfToken = generateToken(req, res);
next();
});
// Make user available to templates
app.use(userContext);
// Serve HTMX from CDN (or bundle it)
app.use(express.static(path.join(__dirname, "public")));
// ---- Auth Routes ----
app.get("/login", (req, res) => {
if (req.session?.user) {
return res.redirect("/dashboard");
}
res.render("login");
});
app.get("/auth/keycloak", async (req, res) => {
const config = getOIDCConfig();
const redirectUri = `${process.env.APP_URL}/auth/callback`;
// Generate PKCE code verifier and challenge
const codeVerifier = openidClient.randomPKCECodeVerifier();
const codeChallenge =
await openidClient.calculatePKCECodeChallenge(codeVerifier);
req.session.codeVerifier = codeVerifier;
const authUrl = openidClient.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: "openid profile email",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(authUrl.href);
});
app.get("/auth/callback", async (req, res) => {
try {
const config = getOIDCConfig();
const redirectUri = `${process.env.APP_URL}/auth/callback`;
const tokens = await openidClient.authorizationCodeGrant(
config,
new URL(req.url, process.env.APP_URL),
{
pkceCodeVerifier: req.session.codeVerifier,
expectedState: openidClient.skipStateCheck,
}
);
const claims = tokens.claims();
// Decode the access token for roles
const accessPayload = JSON.parse(
Buffer.from(
tokens.access_token.split(".")[1],
"base64"
).toString()
);
req.session.user = {
id: claims.sub,
name: claims.name || claims.preferred_username,
email: claims.email,
username: claims.preferred_username,
roles: accessPayload.realm_access?.roles || [],
clientRoles:
accessPayload.resource_access?.["htmx-app"]
?.roles || [],
};
req.session.accessToken = tokens.access_token;
req.session.idToken = tokens.id_token;
req.session.tokenExpiry = accessPayload.exp;
// Clean up PKCE verifier
delete req.session.codeVerifier;
const returnTo = req.session.returnTo || "/dashboard";
delete req.session.returnTo;
res.redirect(returnTo);
} catch (err) {
console.error("Auth callback error:", err);
res.redirect("/login?error=auth_failed");
}
});
app.post("/auth/logout", async (req, res) => {
const config = getOIDCConfig();
const idToken = req.session.idToken;
req.session.destroy(() => {
if (idToken) {
const logoutUrl = openidClient.buildEndSessionUrl(
config,
{
id_token_hint: idToken,
post_logout_redirect_uri: process.env.APP_URL,
}
);
res.redirect(logoutUrl.href);
} else {
res.redirect("/");
}
});
});
// ---- Application Routes ----
app.get("/", (req, res) => {
res.render("home");
});
app.get("/dashboard", requireAuth, (req, res) => {
res.render("dashboard");
});
// HTMX partial endpoints
app.get("/partials/user-profile", requireAuth, (req, res) => {
res.render("partials/user-profile", {
layout: false,
});
});
app.get(
"/partials/admin-panel",
requireRole("admin"),
(req, res) => {
res.render("partials/admin-panel", {
layout: false,
stats: {
totalUsers: 1234,
activeToday: 567,
pendingApprovals: 12,
},
});
}
);
app.post(
"/partials/update-profile",
requireAuth,
doubleCsrfProtection,
(req, res) => {
// Process profile update
const { displayName } = req.body;
// Update user in your database or via Keycloak Admin API
// ...
res.send(`
<div class="success-message" id="profile-result">
Profile updated successfully: ${displayName}
</div>
`);
}
);
// ---- Start Server ----
async function start() {
await initializeOIDC();
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
}
start();
Step 6: HTMX Templates
Layout Template
<!-- src/views/layout.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof title !== 'undefined' ? title : 'HTMX + Keycloak' %></title>
<script src="https://unpkg.com/[email protected]"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, system-ui, sans-serif; background: #f8fafc; color: #1e293b; }
.container { max-width: 960px; margin: 0 auto; padding: 0 1rem; }
nav { background: #0f172a; color: white; padding: 1rem 0; }
nav .container { display: flex; justify-content: space-between; align-items: center; }
nav a { color: white; text-decoration: none; }
.btn { display: inline-block; padding: 8px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 14px; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-danger { background: #ef4444; color: white; }
.card { background: white; border-radius: 12px; padding: 2rem; margin: 1rem 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.success-message { background: #d1fae5; color: #065f46; padding: 12px; border-radius: 6px; margin: 1rem 0; }
.error-message { background: #fee2e2; color: #991b1b; padding: 12px; border-radius: 6px; margin: 1rem 0; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-block; }
.spinner { width: 16px; height: 16px; border: 2px solid #e2e8f0; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
main { padding: 2rem 0; }
dl dt { font-size: 12px; color: #64748b; text-transform: uppercase; margin-top: 1rem; }
dl dd { font-size: 16px; margin-top: 4px; }
form input, form textarea { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; margin-top: 4px; }
form label { display: block; margin-top: 1rem; font-weight: 500; }
</style>
</head>
<body>
<nav>
<div class="container">
<a href="/">HTMX + Keycloak</a>
<div>
<% if (isAuthenticated) { %>
<span style="margin-right: 1rem;">
<%= user.name %>
</span>
<form method="post" action="/auth/logout" style="display:inline;">
<button type="submit" class="btn btn-danger">
Sign out
</button>
</form>
<% } else { %>
<a href="/auth/keycloak" class="btn btn-primary">
Sign in
</a>
<% } %>
</div>
</div>
</nav>
<main>
<div class="container">
<%- body %>
</div>
</main>
<!-- HTMX configuration for CSRF -->
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRF-Token'] = '<%= csrfToken %>';
});
</script>
</body>
</html>
Dashboard Page
<!-- src/views/dashboard.ejs -->
<% var body = `
<h1>Dashboard</h1>
<div class="card">
<h2>Your Profile</h2>
<div
hx-get="/partials/user-profile"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="htmx-indicator">
<div class="spinner"></div> Loading profile...
</div>
</div>
</div>
${user.clientRoles.includes('admin') ? `
<div class="card">
<h2>Admin Panel</h2>
<div
hx-get="/partials/admin-panel"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="htmx-indicator">
<div class="spinner"></div> Loading admin data...
</div>
</div>
</div>
` : ''}
<div class="card">
<h2>Update Display Name</h2>
<form
hx-post="/partials/update-profile"
hx-target="#profile-result"
hx-swap="outerHTML"
>
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
name="displayName"
value="${user.name}"
required
/>
<br /><br />
<button type="submit" class="btn btn-primary">
Update
<span class="htmx-indicator">
<span class="spinner"></span>
</span>
</button>
</form>
<div id="profile-result"></div>
</div>
`; %>
<%- include('layout', { body }) %>
User Profile Partial
<!-- src/views/partials/user-profile.ejs -->
<dl>
<dt>Name</dt>
<dd><%= user.name %></dd>
<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>
Admin Panel Partial
<!-- src/views/partials/admin-panel.ejs -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #3b82f6;">
<%= stats.totalUsers %>
</div>
<div style="color: #64748b;">Total Users</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #10b981;">
<%= stats.activeToday %>
</div>
<div style="color: #64748b;">Active Today</div>
</div>
<div class="card" style="text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #f59e0b;">
<%= stats.pendingApprovals %>
</div>
<div style="color: #64748b;">Pending</div>
</div>
</div>
Step 7: CSRF Protection for HTMX
HTMX sends requests that mutate data (POST, PUT, DELETE), so CSRF protection is essential. The pattern above injects a CSRF token into every HTMX request using the htmx:configRequest event:
document.body.addEventListener("htmx:configRequest", (event) => {
event.detail.headers["X-CSRF-Token"] = "<%= csrfToken %>";
});
The server validates this token on every mutating request via the doubleCsrfProtection middleware. This protects against cross-site request forgery while keeping the HTMX developer experience clean.
Step 8: Running the Application
Create a start script in package.json:
{
"type": "module",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
}
}
Set environment variables and run:
export KEYCLOAK_URL=http://localhost:8080
export KEYCLOAK_REALM=myrealm
export KEYCLOAK_CLIENT_ID=htmx-app
export KEYCLOAK_CLIENT_SECRET=your-secret
export APP_URL=http://localhost:3000
export SESSION_SECRET=a-long-random-string-at-least-32-chars
npm run dev
Security Advantages of This Architecture
The HTMX + server-side session approach eliminates entire categories of security concerns:
| Concern | SPA Approach | HTMX Approach |
|---|---|---|
| Token storage | localStorage/memory (XSS risk) | Server-side session (not accessible to JS) |
| Token refresh | Client-side logic (complex) | Server-side or session expiry (simple) |
| CORS | Required for API calls | Not needed (same-origin) |
| Auth state sync | Complex across tabs/windows | Cookie-based (automatic) |
| API surface | Exposed to client | Server-only |
For more on session management patterns, see session management in distributed systems and Keycloak’s session management features.
Debugging
During development, you can inspect the tokens stored in your server session. Use our JWT Token Analyzer to decode and validate the access token. To check SAML assertions from identity providers brokered through Keycloak, use the SAML Decoder.
For Keycloak event tracking and login analytics, enable audit logging in your realm. If you are using Skycloak, the Insights dashboard provides these metrics out of the box.
Further Reading
- Keycloak Securing Applications Guide
- HTMX Documentation
- openid-client Documentation
- Express.js + Keycloak for Node.js microservices
- BFF pattern with Keycloak — the architectural foundation for server-side auth
- NestJS authentication with Keycloak — another Node.js framework integration
Wrapping Up
HTMX and server-side authentication are a powerful combination. You get dynamic UIs without the security complexity of managing tokens in the browser. Keycloak handles SSO, MFA, and identity federation on the server side, while HTMX handles the UI updates. The result is an application that is simpler to build, simpler to secure, and simpler to maintain.
If you want to focus on your application rather than running Keycloak infrastructure, Skycloak provides managed Keycloak with built-in security and guaranteed uptime. Check our pricing page to find the right plan.