I am Sajan Acharya, a Senior Software Engineer based in Kathmandu. Almost every product I work on eventually hits the same wall: one app becomes three, each with its own login form, its own password table, and its own half-finished “forgot password” flow. Users log in again and again, security reviews find bcrypt rounds that were never tuned, and adding Google login or MFA becomes a quarter-long project. Keycloak removes that entire category of work. It is an open-source identity and access management server that speaks OpenID Connect (OIDC) and OAuth 2.0, so your Node.js services stop owning passwords and start trusting signed tokens.
This guide is the complete integration I ship for clients: Keycloak running locally, a realm and client configured correctly, a TypeScript Express backend that performs the OIDC Authorization Code flow with PKCE, verifies JWTs locally with JWKS, enforces roles, refreshes sessions, logs users out of every app at once, and provisions a local User plus UserRole records inside a single database transaction. The code follows the clean architecture layout from my clean-architecture-code repository—domain, application, infrastructure, presentation, and factories—so Keycloak stays a replaceable detail instead of leaking into every controller. If that layering is new to you, read how to structure a Node.js backend with clean architecture first.
What Keycloak gives you and what your Node.js app still owns
Keycloak owns identity: login pages, password hashing, MFA, social and enterprise identity providers (Google, GitHub, Azure AD, LDAP), account lockout, email verification, and the SSO session itself. Your Node.js app owns authorization decisions and business data: which endpoints need which roles, which user owns which blog post, and what happens the first time a new person signs in. The contract between the two is a set of signed JSON Web Tokens. Once you internalize that split, the integration becomes simple: Node.js redirects to Keycloak to log in, receives tokens back, verifies their signature, and never sees a password.
- Realm: an isolated tenant in Keycloak with its own users, roles, clients, and login settings (we will create a realm called blog)
- Client: an application registered in the realm; our Node.js backend is a confidential client called blog-web because it can safely keep a client secret
- Realm roles: global roles such as admin and editor that appear in the access token under realm_access.roles
- Access token: short-lived JWT your API checks on every request; contains subject, audience, roles, and expiry
- ID token: JWT that proves who logged in to the client; we use it to validate the nonce and to log out
- Refresh token: longer-lived token used to get new access tokens without sending the user back to the login page
- JWKS endpoint: public keys Keycloak publishes so any service can verify token signatures offline
The SSO flow we are building
We will use the OIDC Authorization Code flow with PKCE (Proof Key for Code Exchange). The browser never receives the client secret, and the authorization code is useless to anyone who intercepts it because redeeming it also requires a random code verifier that only our server knows. The Node.js backend acts as a Backend-for-Frontend (BFF): it performs the code exchange, stores tokens in httpOnly cookies that JavaScript cannot read, and exposes clean API routes. The same backend also accepts plain Bearer tokens, so mobile apps and other services can call it too.
Browser Node.js API (client: blog-web) Keycloak (realm: blog)
| GET /auth/login | |
|-------------------------->| create state, nonce, PKCE code verifier |
| 302 → /realms/blog/protocol/openid-connect/auth?code_challenge=... |
|----------------------------------------------------------------------->|
| user logs in (or already has an SSO session) |
| 302 → /auth/callback?code=...&state=... |
|<-----------------------------------------------------------------------|
|-------------------------->| POST /token (code + verifier + secret) |
| |------------------------------------------->|
| |<---- access_token, id_token, refresh ------|
| | verify JWT signatures via JWKS (/certs) |
| | upsert User + UserRole in ONE transaction |
| 302 → returnTo + httpOnly cookies |
|<--------------------------| |
| GET /api/me (cookie or Authorization: Bearer) |
|-------------------------->| verify JWT locally, enforce roles |Where does single sign-on come from? When the user logs in the first time, Keycloak sets its own session cookie on the Keycloak domain. If you later register a second client—say blog-admin for an admin dashboard—and the user opens it, that app redirects to Keycloak exactly like above, Keycloak sees the existing session, and immediately redirects back with a fresh code. No password prompt. Logging out through Keycloak ends that shared session, which is why logout later in this guide goes through Keycloak instead of only deleting local cookies.
Run Keycloak and MongoDB locally with Docker
Keycloak 26 runs as a single container in development mode. MongoDB must run as a replica set—even a single-node one—because multi-document transactions only work on replica sets. We need transactions later to save a user and their roles atomically, so we set that up now instead of discovering it at the worst moment.
# docker-compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start-dev
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
mongo:
image: mongo:7
command: ["--replSet", "rs0", "--bind_ip_all"]
ports:
- "27017:27017"
$ docker compose up -d
$ docker compose exec mongo mongosh --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})'
{ ok: 1 }
# Keycloak admin console → http://localhost:8080 (admin / admin)
$ curl -s http://localhost:8080/realms/master/.well-known/openid-configuration | head -c 120
{"issuer":"http://localhost:8080/realms/master","authorization_endpoint":"http://localhost:8080/realms/master/...Configure the realm, client, roles, and audience
Everything below happens in the Keycloak admin console. Never build apps on the master realm—it is reserved for administering Keycloak itself. Create a dedicated realm per product or environment.
- Create realm: open the realm dropdown → Create realm → name it blog
- Create client: Clients → Create client → Client type OpenID Connect, Client ID blog-web
- Capability config: turn Client authentication ON (confidential client), keep Standard flow ON, turn Direct access grants OFF (no password grant)
- Login settings: Valid redirect URIs http://localhost:4000/auth/callback, Valid post logout redirect URIs http://localhost:4000/*, Web origins http://localhost:4000
- PKCE: Clients → blog-web → Advanced → Proof Key for Code Exchange Code Challenge Method → S256
- Secret: Clients → blog-web → Credentials → copy the Client secret into your .env file
- Roles: Realm roles → Create role admin, then create role editor
- User: Users → Add user (username, email, first and last name) → Credentials → set a password with Temporary OFF → Role mapping → assign editor
- Audience: Clients → blog-web → Client scopes → blog-web-dedicated → Configure a new mapper → Audience → name api-audience, Included Custom Audience blog-api, Add to access token ON
The audience mapper matters more than people expect. By default a Keycloak access token’s aud claim is usually just account, which says nothing about your API. Our Node.js API will only accept tokens whose audience includes blog-api. That prevents a token issued for some unrelated app in the same realm from being replayed against your endpoints. After configuring it, a decoded access token looks roughly like this:
{
"iss": "http://localhost:8080/realms/blog",
"aud": ["blog-api", "account"],
"sub": "5b1c2e9a-7d0f-4a61-9c3e-2f7a8d1b6e40",
"typ": "Bearer",
"azp": "blog-web",
"exp": 1790000000,
"realm_access": { "roles": ["default-roles-blog", "offline_access", "editor"] },
"email": "sajan@example.com",
"name": "Sajan Acharya",
"preferred_username": "sajan"
}Four claims do the heavy lifting in our code: iss must equal our realm URL, aud must contain blog-api, exp must be in the future, and realm_access.roles drives authorization. The sub claim is the stable Keycloak user ID—we store it as keycloakId in our database and never key users by email, because emails change.
Project structure: clean architecture for authentication
The structure mirrors the clean-architecture-code repository. The domain layer holds entities and repository contracts with zero framework imports. The application layer holds use cases and ports—interfaces describing what the use cases need from the outside world, such as an identity provider, a token verifier, and a Unit of Work. Infrastructure implements those ports with Keycloak, jose, and Mongoose. Presentation adapts HTTP: controllers, routes, middlewares, and a factory that wires concrete implementations into use cases. The payoff: swapping Keycloak for Auth0 or Cognito means writing two new infrastructure classes, not touching a single use case.
src/
├── config/
│ └── env.ts
├── domain/
│ ├── entities/
│ │ ├── User.ts
│ │ └── UserRole.ts
│ └── repositories/
│ ├── IUserRepository.ts
│ └── IUserRoleRepository.ts
├── application/
│ ├── errors/
│ │ └── AuthError.ts
│ ├── ports/
│ │ ├── IIdentityProvider.ts
│ │ ├── ITokenVerifier.ts
│ │ └── IUserUnitOfWork.ts
│ └── use-cases/
│ └── auth/
│ ├── StartLoginUseCase.ts
│ ├── HandleCallbackUseCase.ts
│ ├── SyncUserFromIdentityUseCase.ts
│ ├── RefreshSessionUseCase.ts
│ ├── LogoutUseCase.ts
│ └── GetCurrentUserUseCase.ts
├── infrastructure/
│ ├── keycloak/
│ │ ├── KeycloakIdentityProvider.ts
│ │ └── KeycloakTokenVerifier.ts
│ ├── mongoose/
│ │ └── models/
│ │ ├── UserModel.ts
│ │ └── UserRoleModel.ts
│ ├── repositories/
│ │ ├── UserRepository.ts
│ │ └── UserRoleRepository.ts
│ └── transactions/
│ └── MongooseUserUnitOfWork.ts
├── presentation/
│ ├── controllers/
│ │ ├── AuthController.ts
│ │ └── UserController.ts
│ ├── factories/
│ │ └── AuthFactory.ts
│ ├── http/
│ │ ├── cookies.ts
│ │ └── errorHandler.ts
│ ├── middlewares/
│ │ ├── authenticate.ts
│ │ └── requireRole.ts
│ ├── routes/
│ │ ├── authRoutes.ts
│ │ └── userRoutes.ts
│ └── types/
│ └── express.d.ts
└── server.tsHTTP Request
↓
Route → Middleware (authenticate, requireRole)
↓
Controller
↓
Factory (already-wired use cases)
↓
Use Case
↓
Ports: IIdentityProvider | ITokenVerifier | IUserUnitOfWork
↓
Infrastructure: Keycloak (fetch + jose) | Mongoose session
↓
Keycloak server | MongoDBInstall dependencies and configure the environment
We keep dependencies minimal on purpose. Express 5 forwards errors thrown from async handlers to the error middleware automatically, so controllers stay free of try/catch noise. jose verifies JWTs and caches Keycloak’s public keys. We talk to Keycloak’s token endpoint with the built-in fetch available in Node.js 18 and later, which keeps every step of the OIDC flow visible instead of hidden behind a library.
$ mkdir blog-auth-api && cd blog-auth-api
$ npm init -y
$ npm i express@5 cookie-parser jose@5 mongoose dotenv
$ npm i -D typescript tsx @types/node @types/express @types/cookie-parser
$ npx tsc --init
# package.json → "scripts"
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
# tsconfig.json → "compilerOptions"
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
# .env
PORT=4000
APP_URL=http://localhost:4000
MONGO_URI=mongodb://localhost:27017/blog?replicaSet=rs0
COOKIE_SECRET=replace-with-a-long-random-string
KEYCLOAK_ISSUER=http://localhost:8080/realms/blog
KEYCLOAK_CLIENT_ID=blog-web
KEYCLOAK_CLIENT_SECRET=paste-from-credentials-tab
KEYCLOAK_AUDIENCE=blog-apiConfiguration is loaded once and validated at startup. Failing fast on a missing KEYCLOAK_CLIENT_SECRET is far better than discovering it when the first real user clicks Log in.
// src/config/env.ts
import "dotenv/config";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error("Missing environment variable " + name);
return value;
}
const appUrl = required("APP_URL");
export const env = {
port: Number(process.env.PORT ?? 4000),
appUrl,
isProd: process.env.NODE_ENV === "production",
mongoUri: required("MONGO_URI"),
cookieSecret: required("COOKIE_SECRET"),
keycloak: {
issuer: required("KEYCLOAK_ISSUER"),
clientId: required("KEYCLOAK_CLIENT_ID"),
clientSecret: required("KEYCLOAK_CLIENT_SECRET"),
audience: required("KEYCLOAK_AUDIENCE"),
redirectUri: appUrl + "/auth/callback",
postLogoutRedirectUri: appUrl + "/",
},
};
export type KeycloakConfig = typeof env.keycloak;Domain layer: User, UserRole, and repository contracts
Notice what is missing from the User entity: a password. Keycloak owns credentials now, so the domain only models what the business cares about—a stable keycloakId, an email and display name for the UI, and timestamps. Following the repository pattern from the reference project, entities are rebuilt from persisted data through reconstitute(), and repository interfaces live in the domain so that nothing inside it knows MongoDB exists.
// src/domain/entities/User.ts
export type UserProps = {
id: string;
keycloakId: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
};
export class User {
private constructor(private readonly props: UserProps) {}
static reconstitute(props: UserProps): User {
return new User(props);
}
get id(): string {
return this.props.id;
}
get keycloakId(): string {
return this.props.keycloakId;
}
get email(): string {
return this.props.email;
}
get name(): string {
return this.props.name;
}
hasProfileChanged(email: string, name: string): boolean {
return this.props.email !== email || this.props.name !== name;
}
}
// src/domain/entities/UserRole.ts
export class UserRole {
private constructor(
readonly userId: string,
readonly role: string
) {}
static reconstitute(data: { userId: string; role: string }): UserRole {
return new UserRole(data.userId, data.role);
}
}// src/domain/repositories/IUserRepository.ts
import { User } from "../entities/User";
export type CreateUserData = {
keycloakId: string;
email: string;
name: string;
};
export interface IUserRepository {
createUser(data: CreateUserData): Promise<User>;
updateProfile(id: string, data: { email: string; name: string }): Promise<User>;
findById(id: string): Promise<User | null>;
findByKeycloakId(keycloakId: string): Promise<User | null>;
}
// src/domain/repositories/IUserRoleRepository.ts
import { UserRole } from "../entities/UserRole";
export interface IUserRoleRepository {
replaceRoles(userId: string, roles: string[]): Promise<UserRole[]>;
}Why mirror roles locally when they already live in the token? Because the token answers “what can this request do right now,” while your database answers questions like “list every editor who published this month” or “show the audit trail of role changes.” Keycloak stays the source of truth; the local copy is refreshed on every login so reports and joins never need a round-trip to the Keycloak Admin API.
Application layer: ports for Keycloak and the Unit of Work
Ports are the heart of this design. The use cases say what they need—“build me an authorization request,” “exchange this code for tokens,” “verify this access token,” “run these repository calls atomically”—without knowing that Keycloak, jose, or Mongoose will answer. AuthError carries an HTTP-friendly status code so the presentation layer can map failures consistently.
// src/application/errors/AuthError.ts
export class AuthError extends Error {
constructor(
message: string,
readonly statusCode = 401
) {
super(message);
this.name = "AuthError";
}
}
// src/application/ports/IIdentityProvider.ts
export type AuthorizationRequest = {
url: string;
state: string;
nonce: string;
codeVerifier: string;
};
export type TokenSet = {
accessToken: string;
refreshToken: string;
idToken: string;
expiresIn: number;
refreshExpiresIn: number;
};
export interface IIdentityProvider {
createAuthorizationRequest(): AuthorizationRequest;
exchangeCode(code: string, codeVerifier: string): Promise<TokenSet>;
refresh(refreshToken: string): Promise<TokenSet>;
revoke(refreshToken: string): Promise<void>;
buildLogoutUrl(idToken?: string): string;
}
// src/application/ports/ITokenVerifier.ts
export type AuthIdentity = {
subject: string;
email: string;
name: string;
roles: string[];
};
export interface ITokenVerifier {
verifyAccessToken(token: string): Promise<AuthIdentity>;
verifyIdToken(token: string, nonce: string): Promise<{ subject: string }>;
}The Unit of Work port comes straight from the transaction design in the reference repository’s user transaction guide. The application only knows that it can hand over a callback and receive repositories that share one atomic boundary. It does not know about sessions, startTransaction(), commitTransaction(), or abortTransaction().
// src/application/ports/IUserUnitOfWork.ts
import { IUserRepository } from "../../domain/repositories/IUserRepository";
import { IUserRoleRepository } from "../../domain/repositories/IUserRoleRepository";
export interface IUserUnitOfWorkContext {
userRepository: IUserRepository;
userRoleRepository: IUserRoleRepository;
}
export interface IUserUnitOfWork {
execute<T>(operation: (context: IUserUnitOfWorkContext) => Promise<T>): Promise<T>;
}Application layer: the authentication use cases
Each use case does one job. StartLoginUseCase asks the identity provider for a fresh authorization request. HandleCallbackUseCase is the security-critical one: it rejects mismatched state (CSRF protection for the login flow), exchanges the code with the PKCE verifier, validates the ID token’s nonce (replay protection), verifies the access token, confirms both tokens describe the same subject, and only then provisions the local user.
// src/application/use-cases/auth/StartLoginUseCase.ts
import { AuthorizationRequest, IIdentityProvider } from "../../ports/IIdentityProvider";
export class StartLoginUseCase {
constructor(private readonly identityProvider: IIdentityProvider) {}
execute(): AuthorizationRequest {
return this.identityProvider.createAuthorizationRequest();
}
}
// src/application/use-cases/auth/HandleCallbackUseCase.ts
import { AuthError } from "../../errors/AuthError";
import { IIdentityProvider, TokenSet } from "../../ports/IIdentityProvider";
import { ITokenVerifier } from "../../ports/ITokenVerifier";
import { User } from "../../../domain/entities/User";
import { SyncUserFromIdentityUseCase } from "./SyncUserFromIdentityUseCase";
type CallbackInput = {
code: string;
state: string;
expectedState: string;
nonce: string;
codeVerifier: string;
};
export class HandleCallbackUseCase {
constructor(
private readonly identityProvider: IIdentityProvider,
private readonly tokenVerifier: ITokenVerifier,
private readonly syncUser: SyncUserFromIdentityUseCase
) {}
async execute(input: CallbackInput): Promise<{ tokens: TokenSet; user: User }> {
if (!input.code || input.state !== input.expectedState) {
throw new AuthError("Invalid login state");
}
const tokens = await this.identityProvider.exchangeCode(input.code, input.codeVerifier);
const { subject } = await this.tokenVerifier.verifyIdToken(tokens.idToken, input.nonce);
const identity = await this.tokenVerifier.verifyAccessToken(tokens.accessToken);
if (identity.subject !== subject) {
throw new AuthError("Token subject mismatch");
}
const user = await this.syncUser.execute(identity);
return { tokens, user };
}
}SyncUserFromIdentityUseCase is where the transaction lives. On first login it creates the User and its UserRole rows; on later logins it updates the profile if Keycloak data changed and replaces the roles with whatever the token says now. Those writes must succeed or fail together—more on why in the next section. Exactly as in the reference design, the use case never receives repositories directly; it receives the Unit of Work and uses the repositories handed to it inside the callback.
// src/application/use-cases/auth/SyncUserFromIdentityUseCase.ts
import { User } from "../../../domain/entities/User";
import { AuthIdentity } from "../../ports/ITokenVerifier";
import { IUserUnitOfWork } from "../../ports/IUserUnitOfWork";
export class SyncUserFromIdentityUseCase {
constructor(private readonly unitOfWork: IUserUnitOfWork) {}
async execute(identity: AuthIdentity): Promise<User> {
return this.unitOfWork.execute(async ({ userRepository, userRoleRepository }) => {
let user = await userRepository.findByKeycloakId(identity.subject);
if (!user) {
user = await userRepository.createUser({
keycloakId: identity.subject,
email: identity.email,
name: identity.name,
});
} else if (user.hasProfileChanged(identity.email, identity.name)) {
user = await userRepository.updateProfile(user.id, {
email: identity.email,
name: identity.name,
});
}
await userRoleRepository.replaceRoles(user.id, identity.roles);
return user;
});
}
}The remaining use cases are small. RefreshSessionUseCase trades a refresh token for a new token set and verifies the new access token before trusting it. LogoutUseCase revokes the refresh token server-side (so a stolen cookie is useless even if the browser never completes the redirect) and returns the Keycloak logout URL that ends the SSO session. GetCurrentUserUseCase returns the local profile and lazily provisions users who arrive with only a Bearer token—for example, a mobile app that logged in directly with Keycloak and never went through our callback.
// src/application/use-cases/auth/RefreshSessionUseCase.ts
import { IIdentityProvider, TokenSet } from "../../ports/IIdentityProvider";
import { ITokenVerifier } from "../../ports/ITokenVerifier";
export class RefreshSessionUseCase {
constructor(
private readonly identityProvider: IIdentityProvider,
private readonly tokenVerifier: ITokenVerifier
) {}
async execute(refreshToken: string): Promise<TokenSet> {
const tokens = await this.identityProvider.refresh(refreshToken);
await this.tokenVerifier.verifyAccessToken(tokens.accessToken);
return tokens;
}
}
// src/application/use-cases/auth/LogoutUseCase.ts
import { IIdentityProvider } from "../../ports/IIdentityProvider";
export class LogoutUseCase {
constructor(private readonly identityProvider: IIdentityProvider) {}
async execute(input: { refreshToken?: string; idToken?: string }): Promise<string> {
if (input.refreshToken) {
await this.identityProvider.revoke(input.refreshToken).catch(() => undefined);
}
return this.identityProvider.buildLogoutUrl(input.idToken);
}
}
// src/application/use-cases/auth/GetCurrentUserUseCase.ts
import { IUserRepository } from "../../../domain/repositories/IUserRepository";
import { AuthIdentity } from "../../ports/ITokenVerifier";
import { SyncUserFromIdentityUseCase } from "./SyncUserFromIdentityUseCase";
export class GetCurrentUserUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly syncUser: SyncUserFromIdentityUseCase
) {}
async execute(identity: AuthIdentity) {
const user =
(await this.userRepository.findByKeycloakId(identity.subject)) ??
(await this.syncUser.execute(identity));
return { id: user.id, email: user.email, name: user.name, roles: identity.roles };
}
}Why user provisioning needs a transaction
Imagine the first login for a new editor. We insert the User document, then MongoDB hiccups while inserting the UserRole rows. Without a transaction, we now have a user with no roles in our database. Every report that joins on roles silently excludes them, and the next login finds the user already exists and only then fixes roles—if that code path even runs. The same problem appears on role changes: replaceRoles deletes old rows and inserts new ones, and a failure between those two steps leaves the user with zero roles. A transaction makes the whole sync one atomic operation.
# Successful first login
findByKeycloakId → null
createUser ✅
replaceRoles ✅ (delete old rows + insert editor)
commitTransaction ✅ → user and roles become visible together
# Failed first login
findByKeycloakId → null
createUser ✅ (only inside the transaction)
replaceRoles ❌ network error
abortTransaction ↩ → no orphan user, no half-written roles
the user simply retries loginThe critical detail, as the reference guide stresses, is that both repositories must use the same MongoDB session. If UserRepository wrote with session A and UserRoleRepository wrote without a session, the role insert would not be part of the transaction and could never be rolled back. That is exactly why we use a Unit of Work instead of a bare “transaction” helper: the Unit of Work owns the transaction boundary and also constructs transaction-aware repositories that share one session.
Infrastructure: talking to Keycloak with PKCE
KeycloakIdentityProvider implements IIdentityProvider using the standard OIDC endpoints under /realms/blog/protocol/openid-connect. createAuthorizationRequest generates three random values: state (echoed back by Keycloak so we can reject forged callbacks), nonce (embedded in the ID token so it cannot be replayed), and the PKCE code verifier, whose SHA-256 hash is sent as the code challenge. The token endpoint is called with the client secret because blog-web is a confidential client. Keycloak rotates refresh tokens on every refresh, so callers must always store the newest one.
// src/infrastructure/keycloak/KeycloakIdentityProvider.ts
import { createHash, randomBytes } from "node:crypto";
import { AuthError } from "../../application/errors/AuthError";
import {
AuthorizationRequest,
IIdentityProvider,
TokenSet,
} from "../../application/ports/IIdentityProvider";
import { KeycloakConfig } from "../../config/env";
type KeycloakTokenResponse = {
access_token: string;
refresh_token: string;
id_token: string;
expires_in: number;
refresh_expires_in: number;
};
function randomToken(): string {
return randomBytes(32).toString("base64url");
}
export class KeycloakIdentityProvider implements IIdentityProvider {
private readonly endpoints: { auth: string; token: string; logout: string; revoke: string };
constructor(private readonly config: KeycloakConfig) {
const base = config.issuer + "/protocol/openid-connect";
this.endpoints = {
auth: base + "/auth",
token: base + "/token",
logout: base + "/logout",
revoke: base + "/revoke",
};
}
createAuthorizationRequest(): AuthorizationRequest {
const state = randomToken();
const nonce = randomToken();
const codeVerifier = randomToken();
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const url = new URL(this.endpoints.auth);
url.search = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: "code",
scope: "openid profile email",
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();
return { url: url.toString(), state, nonce, codeVerifier };
}
exchangeCode(code: string, codeVerifier: string): Promise<TokenSet> {
return this.requestTokens({
grant_type: "authorization_code",
code,
code_verifier: codeVerifier,
redirect_uri: this.config.redirectUri,
});
}
refresh(refreshToken: string): Promise<TokenSet> {
return this.requestTokens({ grant_type: "refresh_token", refresh_token: refreshToken });
}
async revoke(refreshToken: string): Promise<void> {
await fetch(this.endpoints.revoke, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
token: refreshToken,
token_type_hint: "refresh_token",
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
}
buildLogoutUrl(idToken?: string): string {
const url = new URL(this.endpoints.logout);
url.searchParams.set("client_id", this.config.clientId);
url.searchParams.set("post_logout_redirect_uri", this.config.postLogoutRedirectUri);
if (idToken) url.searchParams.set("id_token_hint", idToken);
return url.toString();
}
private async requestTokens(params: Record<string, string>): Promise<TokenSet> {
const response = await fetch(this.endpoints.token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
...params,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) {
throw new AuthError("Keycloak token request failed with status " + response.status);
}
const body = (await response.json()) as KeycloakTokenResponse;
return {
accessToken: body.access_token,
refreshToken: body.refresh_token,
idToken: body.id_token,
expiresIn: body.expires_in,
refreshExpiresIn: body.refresh_expires_in,
};
}
}Infrastructure: verifying JWTs locally with JWKS
Verifying tokens locally is what keeps Keycloak off your hot path. createRemoteJWKSet downloads Keycloak’s public keys from the certs endpoint once, caches them, and automatically refetches when Keycloak rotates keys and a token arrives with an unknown key ID. jwtVerify checks the RS256 signature, the issuer, the audience, and expiry in one call. Access tokens must carry our API audience (blog-api); ID tokens must carry the client ID (blog-web) and the nonce we generated. Roles are collected from both realm roles and client roles for the API, so you can use either style in Keycloak.
// src/infrastructure/keycloak/KeycloakTokenVerifier.ts
import { createRemoteJWKSet, jwtVerify, JWTPayload } from "jose";
import { AuthError } from "../../application/errors/AuthError";
import { AuthIdentity, ITokenVerifier } from "../../application/ports/ITokenVerifier";
import { KeycloakConfig } from "../../config/env";
type KeycloakClaims = JWTPayload & {
email?: string;
name?: string;
preferred_username?: string;
nonce?: string;
realm_access?: { roles?: string[] };
resource_access?: Record<string, { roles?: string[] }>;
};
export class KeycloakTokenVerifier implements ITokenVerifier {
private readonly jwks: ReturnType<typeof createRemoteJWKSet>;
constructor(private readonly config: KeycloakConfig) {
this.jwks = createRemoteJWKSet(new URL(config.issuer + "/protocol/openid-connect/certs"));
}
async verifyAccessToken(token: string): Promise<AuthIdentity> {
const claims = await this.verify(token, this.config.audience);
return {
subject: claims.sub!,
email: claims.email ?? "",
name: claims.name ?? claims.preferred_username ?? "",
roles: [
...(claims.realm_access?.roles ?? []),
...(claims.resource_access?.[this.config.audience]?.roles ?? []),
],
};
}
async verifyIdToken(token: string, nonce: string): Promise<{ subject: string }> {
const claims = await this.verify(token, this.config.clientId);
if (claims.nonce !== nonce) throw new AuthError("Invalid nonce");
return { subject: claims.sub! };
}
private async verify(token: string, audience: string): Promise<KeycloakClaims> {
try {
const { payload } = await jwtVerify<KeycloakClaims>(token, this.jwks, {
issuer: this.config.issuer,
audience,
algorithms: ["RS256"],
});
if (!payload.sub) throw new AuthError("Token has no subject");
return payload;
} catch {
throw new AuthError("Invalid or expired token");
}
}
}One trade-off to understand: local verification means a revoked user keeps access until their current access token expires. Keep access token lifespans short in Keycloak (Realm settings → Tokens → Access Token Lifespan, five minutes is a sane default) and let the refresh flow do the work. Refreshing hits Keycloak, so a disabled user is cut off at the next refresh. For the rare endpoint that needs instant revocation—transferring money, deleting an account—call Keycloak’s token introspection endpoint in addition to local verification.
Infrastructure: Mongoose models, repositories, and the Unit of Work
The models are plain Mongoose schemas. keycloakId is unique so the database itself guarantees one local user per Keycloak identity, and the compound index on userId plus role prevents duplicate role rows. The repositories accept an optional ClientSession in the constructor: inside the Unit of Work they receive the transaction session, and outside it (for simple reads) they run without one.
// src/infrastructure/mongoose/models/UserModel.ts
import { HydratedDocument, InferSchemaType, Schema, model } from "mongoose";
const userSchema = new Schema(
{
keycloakId: { type: String, required: true, unique: true },
email: { type: String, default: "" },
name: { type: String, default: "" },
},
{ timestamps: true }
);
export type UserDocument = HydratedDocument<InferSchemaType<typeof userSchema>>;
export const UserModel = model("User", userSchema);
// src/infrastructure/mongoose/models/UserRoleModel.ts
import { Schema, model } from "mongoose";
const userRoleSchema = new Schema(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
role: { type: String, required: true },
},
{ timestamps: true }
);
userRoleSchema.index({ userId: 1, role: 1 }, { unique: true });
export const UserRoleModel = model("UserRole", userRoleSchema);Note the array form of create: UserModel.create([data], { session }). Mongoose only accepts options such as session when you pass an array, and forgetting this is the most common reason a “transactional” write quietly escapes the transaction. Every query also calls .session(), so reads inside the Unit of Work see the transaction’s own uncommitted writes.
// src/infrastructure/repositories/UserRepository.ts
import { ClientSession } from "mongoose";
import { User } from "../../domain/entities/User";
import { CreateUserData, IUserRepository } from "../../domain/repositories/IUserRepository";
import { UserDocument, UserModel } from "../mongoose/models/UserModel";
function toDomain(doc: UserDocument): User {
return User.reconstitute({
id: doc._id.toString(),
keycloakId: doc.keycloakId,
email: doc.email,
name: doc.name,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
});
}
export class UserRepository implements IUserRepository {
constructor(private readonly session?: ClientSession) {}
async createUser(data: CreateUserData): Promise<User> {
const [doc] = await UserModel.create([data], { session: this.session });
return toDomain(doc);
}
async updateProfile(id: string, data: { email: string; name: string }): Promise<User> {
const doc = await UserModel.findByIdAndUpdate(id, data, { new: true, session: this.session });
if (!doc) throw new Error("User " + id + " not found");
return toDomain(doc);
}
async findById(id: string): Promise<User | null> {
const doc = await UserModel.findById(id).session(this.session ?? null);
return doc ? toDomain(doc) : null;
}
async findByKeycloakId(keycloakId: string): Promise<User | null> {
const doc = await UserModel.findOne({ keycloakId }).session(this.session ?? null);
return doc ? toDomain(doc) : null;
}
}
// src/infrastructure/repositories/UserRoleRepository.ts
import { ClientSession } from "mongoose";
import { UserRole } from "../../domain/entities/UserRole";
import { IUserRoleRepository } from "../../domain/repositories/IUserRoleRepository";
import { UserRoleModel } from "../mongoose/models/UserRoleModel";
export class UserRoleRepository implements IUserRoleRepository {
constructor(private readonly session?: ClientSession) {}
async replaceRoles(userId: string, roles: string[]): Promise<UserRole[]> {
await UserRoleModel.deleteMany({ userId }, { session: this.session });
const uniqueRoles = [...new Set(roles)];
if (uniqueRoles.length === 0) return [];
const docs = await UserRoleModel.insertMany(
uniqueRoles.map((role) => ({ userId, role })),
{ session: this.session }
);
return docs.map((doc) => UserRole.reconstitute({ userId, role: doc.role }));
}
}Finally, the Mongoose implementation of the Unit of Work. It starts a session and a transaction, creates both repositories with that same session, runs the use case’s callback, and commits. Any thrown error aborts the transaction and is rethrown so the controller can respond. The finally block always ends the session, even on failure, so you never leak sessions under load.
// src/infrastructure/transactions/MongooseUserUnitOfWork.ts
import mongoose from "mongoose";
import {
IUserUnitOfWork,
IUserUnitOfWorkContext,
} from "../../application/ports/IUserUnitOfWork";
import { UserRepository } from "../repositories/UserRepository";
import { UserRoleRepository } from "../repositories/UserRoleRepository";
export class MongooseUserUnitOfWork implements IUserUnitOfWork {
async execute<T>(operation: (context: IUserUnitOfWorkContext) => Promise<T>): Promise<T> {
const session = await mongoose.startSession();
try {
session.startTransaction();
const context: IUserUnitOfWorkContext = {
userRepository: new UserRepository(session),
userRoleRepository: new UserRoleRepository(session),
};
const result = await operation(context);
await session.commitTransaction();
return result;
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
await session.endSession();
}
}
} Same MongoDB session
│
┌──────────┴──────────┐
▼ ▼
UserRepository UserRoleRepository
│ │
create / update delete + insert roles
└──────────┬──────────┘
▼
commitTransaction() or abortTransaction()Presentation: cookies, controllers, middleware, and routes
The presentation layer is where HTTP details live, and cookies are an HTTP detail. The short-lived kc_login cookie is signed and holds state, nonce, code verifier, and the return path for the few seconds between redirecting to Keycloak and coming back. Session cookies are httpOnly (unreadable by JavaScript, which neutralizes token theft via XSS), secure in production, and SameSite=Lax. The refresh and ID tokens are scoped to the /auth path so they are only sent to the endpoints that need them. safeReturnTo resolves the requested path against our own origin and falls back to the home page for anything external, which closes the classic open-redirect hole in login flows.
// src/presentation/http/cookies.ts
import { CookieOptions, Response } from "express";
import { TokenSet } from "../../application/ports/IIdentityProvider";
import { env } from "../../config/env";
export const LOGIN_COOKIE = "kc_login";
export const ACCESS_COOKIE = "access_token";
export const REFRESH_COOKIE = "refresh_token";
export const ID_TOKEN_COOKIE = "id_token";
const base: CookieOptions = { httpOnly: true, secure: env.isProd, sameSite: "lax" };
const authPath: CookieOptions = { ...base, path: "/auth" };
export const loginCookieOptions: CookieOptions = {
...authPath,
signed: true,
maxAge: 5 * 60 * 1000,
};
export function setSessionCookies(res: Response, tokens: TokenSet): void {
res.cookie(ACCESS_COOKIE, tokens.accessToken, { ...base, maxAge: tokens.expiresIn * 1000 });
res.cookie(REFRESH_COOKIE, tokens.refreshToken, { ...authPath, maxAge: tokens.refreshExpiresIn * 1000 });
res.cookie(ID_TOKEN_COOKIE, tokens.idToken, { ...authPath, maxAge: tokens.refreshExpiresIn * 1000 });
}
export function clearSessionCookies(res: Response): void {
res.clearCookie(ACCESS_COOKIE, base);
res.clearCookie(REFRESH_COOKIE, authPath);
res.clearCookie(ID_TOKEN_COOKIE, authPath);
}
export function safeReturnTo(value: unknown): string {
if (typeof value !== "string") return "/";
try {
const appOrigin = new URL(env.appUrl).origin;
const target = new URL(value, env.appUrl);
return target.origin === appOrigin ? target.pathname + target.search : "/";
} catch {
return "/";
}
}
// src/presentation/http/errorHandler.ts
import { ErrorRequestHandler } from "express";
import { AuthError } from "../../application/errors/AuthError";
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
if (error instanceof AuthError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
console.error(error);
res.status(500).json({ error: "Internal server error" });
};The AuthController stays thin: read input from the request, call a use case from the factory, write cookies, redirect. Notice there is no Keycloak URL, no fetch, and no JWT parsing here. Because we run Express 5, a rejected promise from any async handler goes straight to errorHandler, which maps AuthError to 401 or 403. Logout is a POST so a malicious image tag on another site cannot log your users out, and it answers with a 303 redirect so the browser follows it with a GET to Keycloak’s logout endpoint.
// src/presentation/controllers/AuthController.ts
import { Request, Response } from "express";
import { AuthError } from "../../application/errors/AuthError";
import { AuthFactory } from "../factories/AuthFactory";
import {
ID_TOKEN_COOKIE,
LOGIN_COOKIE,
REFRESH_COOKIE,
clearSessionCookies,
loginCookieOptions,
safeReturnTo,
setSessionCookies,
} from "../http/cookies";
type PendingLogin = {
state: string;
nonce: string;
codeVerifier: string;
returnTo: string;
};
export class AuthController {
constructor(private readonly factory: AuthFactory) {}
login = (req: Request, res: Response): void => {
const request = this.factory.startLoginUseCase.execute();
const pending: PendingLogin = {
state: request.state,
nonce: request.nonce,
codeVerifier: request.codeVerifier,
returnTo: safeReturnTo(req.query.returnTo),
};
res.cookie(LOGIN_COOKIE, JSON.stringify(pending), loginCookieOptions);
res.redirect(request.url);
};
callback = async (req: Request, res: Response): Promise<void> => {
const raw: unknown = req.signedCookies[LOGIN_COOKIE];
res.clearCookie(LOGIN_COOKIE, loginCookieOptions);
if (typeof req.query.error === "string") {
throw new AuthError("Keycloak returned " + req.query.error);
}
if (typeof raw !== "string") {
throw new AuthError("Login session expired, please try again");
}
const pending = JSON.parse(raw) as PendingLogin;
const { tokens } = await this.factory.handleCallbackUseCase.execute({
code: String(req.query.code ?? ""),
state: String(req.query.state ?? ""),
expectedState: pending.state,
nonce: pending.nonce,
codeVerifier: pending.codeVerifier,
});
setSessionCookies(res, tokens);
res.redirect(pending.returnTo);
};
refresh = async (req: Request, res: Response): Promise<void> => {
const refreshToken: unknown = req.cookies[REFRESH_COOKIE];
if (typeof refreshToken !== "string") throw new AuthError("No refresh token");
try {
const tokens = await this.factory.refreshSessionUseCase.execute(refreshToken);
setSessionCookies(res, tokens);
res.status(204).end();
} catch (error) {
clearSessionCookies(res);
throw error;
}
};
logout = async (req: Request, res: Response): Promise<void> => {
const logoutUrl = await this.factory.logoutUseCase.execute({
refreshToken: req.cookies[REFRESH_COOKIE],
idToken: req.cookies[ID_TOKEN_COOKIE],
});
clearSessionCookies(res);
res.redirect(303, logoutUrl);
};
}Two middlewares protect the API. authenticate accepts either an Authorization: Bearer header (mobile apps, other services, Postman) or the httpOnly access_token cookie (our browser session), verifies it through the ITokenVerifier port, and attaches the identity to req.auth. requireRole is a tiny, composable guard that returns 403 when the identity lacks every listed role. Returning 401 for “who are you?” and 403 for “I know who you are, and no” keeps client error handling predictable.
// src/presentation/types/express.d.ts
import { AuthIdentity } from "../../application/ports/ITokenVerifier";
declare global {
namespace Express {
interface Request {
auth?: AuthIdentity;
}
}
}
export {};
// src/presentation/middlewares/authenticate.ts
import { NextFunction, Request, Response } from "express";
import { AuthError } from "../../application/errors/AuthError";
import { ITokenVerifier } from "../../application/ports/ITokenVerifier";
import { ACCESS_COOKIE } from "../http/cookies";
export function authenticate(verifier: ITokenVerifier) {
return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : req.cookies[ACCESS_COOKIE];
if (typeof token !== "string" || !token) throw new AuthError("Missing access token");
req.auth = await verifier.verifyAccessToken(token);
next();
};
}
// src/presentation/middlewares/requireRole.ts
import { NextFunction, Request, Response } from "express";
import { AuthError } from "../../application/errors/AuthError";
export function requireRole(...roles: string[]) {
return (req: Request, _res: Response, next: NextFunction): void => {
if (!req.auth) throw new AuthError("Not authenticated");
if (!roles.some((role) => req.auth!.roles.includes(role))) {
throw new AuthError("Forbidden", 403);
}
next();
};
}The factory is the single place that knows about concrete classes, exactly like UserFactory in the reference repository. It builds the Keycloak adapters and the Mongoose Unit of Work once and injects them into every use case. Controllers ask the factory for ready-to-use use cases and never call new on a repository. In tests, you build use cases with fake identity providers and an in-memory Unit of Work instead—no Keycloak or MongoDB required.
// src/presentation/factories/AuthFactory.ts
import { GetCurrentUserUseCase } from "../../application/use-cases/auth/GetCurrentUserUseCase";
import { HandleCallbackUseCase } from "../../application/use-cases/auth/HandleCallbackUseCase";
import { LogoutUseCase } from "../../application/use-cases/auth/LogoutUseCase";
import { RefreshSessionUseCase } from "../../application/use-cases/auth/RefreshSessionUseCase";
import { StartLoginUseCase } from "../../application/use-cases/auth/StartLoginUseCase";
import { SyncUserFromIdentityUseCase } from "../../application/use-cases/auth/SyncUserFromIdentityUseCase";
import { env } from "../../config/env";
import { KeycloakIdentityProvider } from "../../infrastructure/keycloak/KeycloakIdentityProvider";
import { KeycloakTokenVerifier } from "../../infrastructure/keycloak/KeycloakTokenVerifier";
import { UserRepository } from "../../infrastructure/repositories/UserRepository";
import { MongooseUserUnitOfWork } from "../../infrastructure/transactions/MongooseUserUnitOfWork";
export class AuthFactory {
private readonly identityProvider = new KeycloakIdentityProvider(env.keycloak);
private readonly unitOfWork = new MongooseUserUnitOfWork();
private readonly syncUser = new SyncUserFromIdentityUseCase(this.unitOfWork);
readonly tokenVerifier = new KeycloakTokenVerifier(env.keycloak);
readonly startLoginUseCase = new StartLoginUseCase(this.identityProvider);
readonly handleCallbackUseCase = new HandleCallbackUseCase(
this.identityProvider,
this.tokenVerifier,
this.syncUser
);
readonly refreshSessionUseCase = new RefreshSessionUseCase(
this.identityProvider,
this.tokenVerifier
);
readonly logoutUseCase = new LogoutUseCase(this.identityProvider);
readonly getCurrentUserUseCase = new GetCurrentUserUseCase(
new UserRepository(),
this.syncUser
);
}// src/presentation/controllers/UserController.ts
import { Request, Response } from "express";
import { AuthError } from "../../application/errors/AuthError";
import { AuthFactory } from "../factories/AuthFactory";
export class UserController {
constructor(private readonly factory: AuthFactory) {}
me = async (req: Request, res: Response): Promise<void> => {
if (!req.auth) throw new AuthError("Not authenticated");
res.json(await this.factory.getCurrentUserUseCase.execute(req.auth));
};
}
// src/presentation/routes/authRoutes.ts
import { Router } from "express";
import { AuthController } from "../controllers/AuthController";
export function authRoutes(controller: AuthController): Router {
const router = Router();
router.get("/login", controller.login);
router.get("/callback", controller.callback);
router.post("/refresh", controller.refresh);
router.post("/logout", controller.logout);
return router;
}
// src/presentation/routes/userRoutes.ts
import { Router } from "express";
import { ITokenVerifier } from "../../application/ports/ITokenVerifier";
import { UserController } from "../controllers/UserController";
import { authenticate } from "../middlewares/authenticate";
import { requireRole } from "../middlewares/requireRole";
export function userRoutes(controller: UserController, verifier: ITokenVerifier): Router {
const router = Router();
const requireAuth = authenticate(verifier);
router.get("/me", requireAuth, controller.me);
router.post("/posts", requireAuth, requireRole("editor", "admin"), (req, res) => {
res.status(201).json({ title: req.body.title, authorId: req.auth!.subject });
});
router.get("/admin/overview", requireAuth, requireRole("admin"), (req, res) => {
res.json({ message: "Welcome, " + req.auth!.name });
});
return router;
}server.ts is the composition root. It connects to MongoDB, registers cookie-parser with the secret used to sign the kc_login cookie, mounts routes, and registers the error handler last. trust proxy is enabled so secure cookies work behind a load balancer or reverse proxy that terminates TLS.
// src/server.ts
import cookieParser from "cookie-parser";
import express from "express";
import mongoose from "mongoose";
import { env } from "./config/env";
import { AuthController } from "./presentation/controllers/AuthController";
import { UserController } from "./presentation/controllers/UserController";
import { AuthFactory } from "./presentation/factories/AuthFactory";
import { errorHandler } from "./presentation/http/errorHandler";
import { authRoutes } from "./presentation/routes/authRoutes";
import { userRoutes } from "./presentation/routes/userRoutes";
async function bootstrap(): Promise<void> {
await mongoose.connect(env.mongoUri);
const app = express();
app.set("trust proxy", 1);
app.use(express.json());
app.use(cookieParser(env.cookieSecret));
const authFactory = new AuthFactory();
app.use("/auth", authRoutes(new AuthController(authFactory)));
app.use("/api", userRoutes(new UserController(authFactory), authFactory.tokenVerifier));
app.use(errorHandler);
app.listen(env.port, () => console.log("API listening on " + env.appUrl));
}
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
});Test the full SSO flow end to end
Start the API and walk through the flow in a browser first, then use curl to check the negative paths. Test the failures on purpose: most auth bugs are endpoints that accidentally allow the wrong request, not endpoints that reject the right one.
$ npm run dev
API listening on http://localhost:4000
# 1. Browser: open the login route with a return path
# → redirected to Keycloak, log in as your editor user
# → redirected back to /api/me with httpOnly cookies set
http://localhost:4000/auth/login?returnTo=/api/me
{"id":"66f9c1...","email":"sajan@example.com","name":"Sajan Acharya",
"roles":["default-roles-blog","offline_access","uma_authorization","editor"]}
# 2. No token → 401
$ curl -i http://localhost:4000/api/me
HTTP/1.1 401 Unauthorized
{"error":"Missing access token"}
# 3. Copy the access_token cookie from DevTools, then call as editor
$ export ACCESS_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIi..."
$ curl -i -X POST http://localhost:4000/api/posts -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" -d '{"title":"Keycloak in Node.js"}'
HTTP/1.1 201 Created
{"title":"Keycloak in Node.js","authorId":"5b1c2e9a-7d0f-4a61-9c3e-2f7a8d1b6e40"}
# 4. Editor calling an admin route → 403
$ curl -i http://localhost:4000/api/admin/overview -H "Authorization: Bearer $ACCESS_TOKEN"
HTTP/1.1 403 Forbidden
{"error":"Forbidden"}
# 5. Tampered token → 401 (signature check fails)
$ curl -i http://localhost:4000/api/me -H "Authorization: Bearer $ACCESS_TOKEN-tampered"
HTTP/1.1 401 Unauthorized
{"error":"Invalid or expired token"}
# 6. The transaction wrote user + roles together
$ mongosh "mongodb://localhost:27017/blog?replicaSet=rs0" --eval 'db.userroles.find({}, {role:1, _id:0}).toArray()'
[ { role: 'default-roles-blog' }, { role: 'offline_access' }, { role: 'uma_authorization' }, { role: 'editor' } ]Now test SSO and role sync. In Keycloak, assign the admin role to the same user, then trigger a refresh (POST /auth/refresh from the browser) or log in again: the new access token carries admin, /api/admin/overview starts returning 200, and the userroles collection is replaced inside a transaction. Register a second client such as blog-admin with its own redirect URI, point another app at it, and open it in the same browser—you land inside without seeing the login form. Finally, submit a form that POSTs to /auth/logout: both apps lose their session because Keycloak ended the shared SSO session.
Protecting other services and machine-to-machine calls
Other Node.js microservices do not need the login flow at all. They act as pure resource servers: reuse KeycloakTokenVerifier plus the authenticate and requireRole middlewares, and they can verify the same tokens with nothing but the realm’s public keys. That is what makes Keycloak work well in a scaled architecture—any instance behind a load balancer can verify any request without sticky sessions or a shared session store, which pairs naturally with the stateless principles in how to scale APIs with Node.js.
For background workers and cron jobs that call your API without a user, create a separate confidential client (for example blog-worker) with Service accounts roles ON and Standard flow OFF, add the same blog-api audience mapper, and assign the roles the worker needs under the Service account roles tab. The worker then uses the OAuth 2.0 client credentials grant. Service account tokens have no human email, so keep them on routes guarded by requireRole rather than routes that provision users.
// worker/getServiceToken.ts — client credentials grant
type ServiceToken = { value: string; expiresAt: number };
let cached: ServiceToken | null = null;
export async function getServiceToken(): Promise<string> {
if (cached && cached.expiresAt > Date.now() + 30_000) return cached.value;
const response = await fetch(process.env.KEYCLOAK_ISSUER + "/protocol/openid-connect/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: "blog-worker",
client_secret: process.env.WORKER_CLIENT_SECRET ?? "",
}),
});
if (!response.ok) throw new Error("Could not obtain service token: " + response.status);
const body = (await response.json()) as { access_token: string; expires_in: number };
cached = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
return cached.value;
}
// Usage
const token = await getServiceToken();
await fetch("http://localhost:4000/api/admin/overview", {
headers: { Authorization: "Bearer " + token },
});Production checklist and common pitfalls
The development setup above is deliberately simple. Before real users touch it, work through this list—each item comes from an incident or audit finding I have seen on real Keycloak projects.
- Run Keycloak with start (not start-dev), a PostgreSQL database, TLS, and KC_HOSTNAME set to the public URL; the iss claim must exactly match KEYCLOAK_ISSUER or every token will fail verification
- When Node.js reaches Keycloak through an internal Docker hostname, the issuer still has to be the public URL; configure Keycloak’s hostname rather than loosening the issuer check
- Keep access tokens short (around 5 minutes) and rely on refresh; use token introspection only for the few endpoints that need instant revocation
- Enable Revoke Refresh Token in Realm settings → Tokens so a refresh token can be used once; our code already stores the rotated token each time
- If access tokens grow large (many roles or groups), move tokens into a server-side session store such as Redis and put only an opaque session ID in the cookie to stay under the 4 KB cookie limit
- MongoDB transactions require a replica set in every environment, including CI; a standalone mongod fails with “Transaction numbers are only allowed on a replica set member or mongos”
- Two simultaneous first logins can race on the unique keycloakId index; retry SyncUserFromIdentityUseCase once on duplicate-key error (E11000) and the second attempt finds the existing user
- Consider session.withTransaction() inside MongooseUserUnitOfWork in high-traffic systems; it retries transient write conflicts automatically while keeping the same Unit of Work contract
- In Keycloak 25 and later the sub claim comes from the basic client scope; do not remove that scope from your clients or tokens will have no subject
- Never enable Direct access grants (password grant) for browser apps; it bypasses MFA, brute-force detection, and the whole point of SSO
- Add CSRF protection for state-changing cookie-authenticated routes beyond SameSite=Lax if you support older browsers or cross-site embeds
- Log authentication failures with reason codes (expired, bad audience, bad signature) but never log raw tokens or cookies
Wrapping up
You now have a complete Keycloak integration in Node.js with TypeScript: Authorization Code with PKCE for browser login, state and nonce validation, JWKS-based local verification with issuer and audience checks, role-based middleware, refresh-token rotation, logout that ends the SSO session everywhere, a client credentials path for services, and a Unit of Work that keeps User and UserRole consistent even when the database fails halfway. Just as important, Keycloak sits behind two ports. Your use cases, entities, and tests would not change if you moved to a different OIDC provider tomorrow.
If you are adding SSO to an existing Node.js platform, migrating users from a homegrown password table into Keycloak, or designing multi-tenant realms, my Node.js developer services cover exactly that kind of work. Get in touch with your current auth setup and the apps you want under one login, and we can plan a migration that does not force every user to reset their password on day one.