1. Introduction: Why Storing Tokens in the Browser is Dead
For years, the standard authentication pattern for Single-Page Applications (SPA) was retrieving an access_token (JWT) and a refresh_token from an Identity Provider (Keycloak, Auth0, Okta), and storing them directly inside the client browser:
- In
localStorageorsessionStorage: 100% vulnerable to any Cross-Site Scripting (XSS) exploit. Any third-party NPM dependency (supply chain attack) or malicious HTML injection can read storage and exfiltrate refresh tokens, granting persistent access to your infrastructure. - In an insecure JavaScript-accessible cookie: Same exfiltration vulnerabilities.
In response, the IETF OAuth Working Group (draft-ietf-oauth-browser-based-apps) and OWASP officially recommend the Backend-For-Frontend (BFF) pattern combined with the Token Handler pattern.
Core Principles of a Pure BFF
- Zero tokens in the browser: The frontend client never sees or handles raw JWTs (
access_token,refresh_token,id_token). - Encrypted HttpOnly & SameSite session cookie: The browser only holds an encrypted session cookie inaccessible to JavaScript (
httpOnly: true,secure: true,sameSite: 'lax'). - Server-side Token Handler: The BFF server manages OIDC tokens, executes background refreshes, and actively verifies session liveness against the IAM server.
2. Global Architecture Diagram (ASCII)
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ BROWSER (SPA CLIENT) │
│ - Vue 3 / React / Nuxt │
│ - HttpOnly Cookie: aivo_session (AES-256 Encrypted, max 500 bytes) │
│ - Memory state: Short-lived anti-CSRF token │
└───────────────────┬──────────────────────────────────────────────▲─────────────────────┘
│ │
(1) HTTPS POST │ [Cookie: aivo_session] │ (8) 302 Redirect
with credentials│ [X-CSRF-Token: ...] │ Set-Cookie
▼ │
┌──────────────────────────────────────────────────────────────────┴─────────────────────┐
│ BACKEND-FOR-FRONTEND (BFF) │
│ - Fastify / Express in Node.js TypeScript │
│ - AES-256-GCM client cookie encryption │
│ - Strict anti-CSRF gateway hook │
│ │
│ ┌────────────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ Session Store (In-Memory) │ <===> │ PostgreSQL (user_sessions) │ │
│ │ Cache < 1ms for OIDC tokens │ │ Inter-container persistence │ │
│ └───────────────▲────────────────┘ └──────────────────▲───────────────────┘ │
└───────────────────┼───────────────────────────────────────────┼────────────────────────┘
│ │
(3) Code Exch. │ (5) UserInfo / Token Refresh │ (7) Backchannel Logout
+ PKCE S256 │ (Active session validation) │ (Direct revocation)
▼ ▼
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ IAM IDENTITY PROVIDER (KEYCLOAK) │
│ - OpenID Connect 1.0 & OAuth 2.0 Identity Provider │
│ - Single Sign-On (SSO) & Realm active sessions management │
└────────────────────────────────────────────────────────────────────────────────────────┘
3. The Hidden Cookie Trap: “Cookie Overflow” (4 KB)
When transitioning to a BFF, a common trap is storing OIDC tokens (access_token, refresh_token, id_token) directly inside the encrypted cookie.
Why this silently breaks your application:
- A complete Keycloak
access_tokenJWT often weighs between 1,000 and 1,500 bytes. - A
refresh_tokenweighs between 900 and 1,200 bytes. - An
id_tokenweighs between 1,000 and 1,400 bytes. - Raw JSON payload: ~3.8 KB.
- After AES-256-GCM symmetric encryption and hex encoding, the cookie header exceeds 7,700 to 8,000 bytes!
Warning — RFC 6265 HTTP Cookie Limit:
All modern browsers (Chrome, Firefox, Safari, Edge) and reverse-proxies (Traefik, Nginx) silently drop cookies larger than 4,096 bytes.Typical Symptom: Upon login, the OIDC callback returns a 302 redirect with the oversized cookie. The browser discards it without an error. On the very next request (
/api/auth/me), the user receives a401 Unauthorizedbecause no cookie was stored or sent!
The Solution: Hybrid Server-Side Session Store
- In the browser cookie: Only a random cryptographic
session_id(32 bytes hex), basic public user profile fields (id,name,email,role), and thecsrf_token.- Total size: ~500 bytes (less than 15% of the browser limit).
- On the BFF server: Full OIDC tokens reside in an ultra-fast in-memory cache (
< 1 mslookup) backed by a PostgreSQL database (user_sessionstable) to survive container restarts.
4. Server-Side Implementation: Production-Ready Code
4.1. Database Schema (schema.sql)
-- Persistence table for sovereign BFF sessions
CREATE TABLE IF NOT EXISTS user_sessions (
session_id VARCHAR(128) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
user_data JSONB NOT NULL,
tokens JSONB NOT NULL,
last_verified_at BIGINT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
4.2. Server Session Store (services/sessionStore.ts)
import pg from 'pg';
export interface UserInfo {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
export interface KeycloakTokens {
access_token: string;
refresh_token: string;
id_token?: string;
expires_at?: number;
}
export interface StoredSession {
sessionId: string;
userId: string;
user: UserInfo;
tokens: KeycloakTokens;
lastVerifiedAt: number;
}
// Ultra-fast in-memory cache (< 1ms)
const memorySessions = new Map<string, StoredSession>();
// PostgreSQL connection pool
export let pool: pg.Pool;
export function setDbPool(dbPool: pg.Pool) {
pool = dbPool;
}
export async function saveSession(
sessionId: string,
user: UserInfo,
tokens: KeycloakTokens
): Promise<void> {
const record: StoredSession = {
sessionId,
userId: user.id,
user,
tokens,
lastVerifiedAt: Date.now(),
};
// 1. RAM cache
memorySessions.set(sessionId, record);
// 2. PostgreSQL persistence
if (pool) {
try {
await pool.query(
`INSERT INTO user_sessions (session_id, user_id, user_data, tokens, last_verified_at, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
ON CONFLICT (session_id) DO UPDATE SET
user_data = EXCLUDED.user_data,
tokens = EXCLUDED.tokens,
last_verified_at = EXCLUDED.last_verified_at,
updated_at = CURRENT_TIMESTAMP`,
[sessionId, user.id, JSON.stringify(user), JSON.stringify(tokens), Date.now()]
);
} catch (err) {
console.warn('[SessionStore] Error saving session to SQL:', err);
}
}
}
export async function getSession(sessionId: string): Promise<StoredSession | null> {
if (!sessionId) return null;
// 1. Read from RAM
const cached = memorySessions.get(sessionId);
if (cached) return cached;
// 2. Read from SQL on cold start
if (pool) {
try {
const res = await pool.query(
`SELECT session_id, user_id, user_data, tokens, last_verified_at
FROM user_sessions WHERE session_id = $1`,
[sessionId]
);
if (res.rows && res.rows.length > 0) {
const row = res.rows[0];
const record: StoredSession = {
sessionId: row.session_id,
userId: row.user_id,
user: typeof row.user_data === 'string' ? JSON.parse(row.user_data) : row.user_data,
tokens: typeof row.tokens === 'string' ? JSON.parse(row.tokens) : row.tokens,
lastVerifiedAt: Number(row.last_verified_at || 0),
};
memorySessions.set(sessionId, record);
return record;
}
} catch (err) {
console.warn('[SessionStore] Error reading session from SQL:', err);
}
}
return null;
}
export async function updateSessionTokens(
sessionId: string,
tokens: KeycloakTokens
): Promise<void> {
const session = await getSession(sessionId);
if (!session) return;
session.tokens = tokens;
memorySessions.set(sessionId, session);
if (pool) {
try {
await pool.query(
`UPDATE user_sessions SET tokens = $1, updated_at = CURRENT_TIMESTAMP WHERE session_id = $2`,
[JSON.stringify(tokens), sessionId]
);
} catch (err) {
console.warn('[SessionStore] Error updating tokens in SQL:', err);
}
}
}
export async function updateLastVerifiedAt(sessionId: string): Promise<void> {
const now = Date.now();
const session = memorySessions.get(sessionId);
if (session) {
session.lastVerifiedAt = now;
}
if (pool) {
try {
await pool.query(
`UPDATE user_sessions SET last_verified_at = $1 WHERE session_id = $2`,
[now, sessionId]
);
} catch {}
}
}
export async function deleteSession(sessionId: string): Promise<void> {
memorySessions.delete(sessionId);
if (pool) {
try {
await pool.query(`DELETE FROM user_sessions WHERE session_id = $1`, [sessionId]);
} catch (err) {
console.warn('[SessionStore] Error deleting session from SQL:', err);
}
}
}
4.3. Encrypted HttpOnly Cookie Plugin (plugins/session.ts)
import fp from 'fastify-plugin';
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import crypto from 'crypto';
export interface UserInfo {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
export interface SessionData {
session_id?: string;
user?: UserInfo;
csrf_token?: string;
oidc_state?: string;
oidc_nonce?: string;
oidc_code_verifier?: string;
[key: string]: any;
}
export class Session {
private data: SessionData;
private modified = false;
constructor(initialData: SessionData = {}) {
this.data = { ...initialData };
}
get<K extends keyof SessionData>(key: K): SessionData[K] {
return this.data[key];
}
set<K extends keyof SessionData>(key: K, value: SessionData[K]): void {
this.data[key] = value;
this.modified = true;
}
delete(): void {
this.data = {};
this.modified = true;
}
isModified(): boolean {
return this.modified;
}
toJSON(): SessionData {
return this.data;
}
}
declare module 'fastify' {
interface FastifyRequest {
session: Session;
}
}
function encrypt(data: object, key: Buffer): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const json = JSON.stringify(data);
let encrypted = cipher.update(json, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`;
}
function decrypt(encryptedString: string, key: Buffer): object | null {
try {
const parts = encryptedString.split(':');
if (parts.length !== 3) return null;
const iv = Buffer.from(parts[0], 'hex');
const tag = Buffer.from(parts[1], 'hex');
const ciphertext = parts[2];
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
} catch {
return null;
}
}
export default fp(async function sessionPlugin(fastify: FastifyInstance, opts: { secret: Buffer; cookieName?: string; maxAge?: number }) {
const cookieName = opts.cookieName || 'app_session';
const secretKey = opts.secret;
const maxAge = opts.maxAge || 7 * 24 * 60 * 60; // 7 days
// Decrypt incoming session
fastify.addHook('onRequest', async (request: FastifyRequest) => {
const cookieValue = request.cookies[cookieName];
let initialData = {};
if (cookieValue) {
const decrypted = decrypt(cookieValue, secretKey);
if (decrypted) initialData = decrypted;
}
request.session = new Session(initialData);
});
// Encrypt or clear cookie on send
fastify.addHook('onSend', async (request: FastifyRequest, reply: FastifyReply) => {
if (request.session && request.session.isModified()) {
const sessionData = request.session.toJSON();
let domain = process.env.COOKIE_DOMAIN;
if (!domain) {
const reqHost = (request.headers['x-forwarded-host'] || request.headers.host || '') as string;
const cleanHost = reqHost.split(':')[0];
if (cleanHost.endsWith('example.com')) {
domain = '.example.com';
}
}
const isSecure = process.env.COOKIE_SECURE === 'true' || request.protocol === 'https' || process.env.NODE_ENV === 'production';
const sameSiteOption = (process.env.COOKIE_SAMESITE as 'lax' | 'none' | 'strict') || 'lax';
if (Object.keys(sessionData).length === 0) {
const clearOptions = {
path: '/',
httpOnly: true,
secure: isSecure,
sameSite: sameSiteOption,
};
if (domain) {
reply.clearCookie(cookieName, { ...clearOptions, domain });
}
reply.clearCookie(cookieName, clearOptions);
} else {
const encrypted = encrypt(sessionData, secretKey);
reply.setCookie(cookieName, encrypted, {
path: '/',
httpOnly: true,
secure: isSecure,
sameSite: sameSiteOption,
domain,
maxAge,
});
}
}
});
});
4.4. OIDC Lifecycle Service (services/keycloakService.ts)
import { FastifyBaseLogger } from 'fastify';
import { Session, UserInfo } from '../plugins/session.js';
import {
getSession,
updateSessionTokens,
updateLastVerifiedAt,
deleteSession,
KeycloakTokens
} from './sessionStore.js';
export function getKeycloakConfig() {
const rawKeycloakUrl = process.env.KEYCLOAK_URL;
return {
keycloakUrl: rawKeycloakUrl ? rawKeycloakUrl.replace(/\/+$/, '') : undefined,
realm: process.env.KEYCLOAK_REALM || 'main',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'app-spa',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
redirectUri: process.env.REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
frontendUrl: (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/+$/, ''),
};
}
/**
* Actively validates session with Keycloak (UserInfo) with silent refresh
*/
export async function verifyOrRefreshKeycloakSession(
session: Session,
log?: FastifyBaseLogger
): Promise<boolean> {
const { keycloakUrl, realm, clientId, clientSecret } = getKeycloakConfig();
if (!keycloakUrl) return true; // Mock mode
const sessionId = session.get('session_id');
if (!sessionId) return false;
const stored = await getSession(sessionId);
if (!stored || !stored.tokens || !stored.tokens.access_token) return false;
const { tokens } = stored;
const now = Date.now();
const isAccessTokenExpired = tokens.expires_at ? now >= tokens.expires_at : false;
// Short 15s cache to avoid IAM overload on rapid clicks
if (now - (stored.lastVerifiedAt || 0) < 15000 && !isAccessTokenExpired) {
return true;
}
const userInfoEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/userinfo`;
// 1. Verify current access token with UserInfo
if (!isAccessTokenExpired) {
try {
const userRes = await fetch(userInfoEndpoint, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (userRes.ok) {
const rawUser = await userRes.json() as any;
if (rawUser && rawUser.sub) {
const currentUser = session.get('user') || stored.user;
session.set('user', {
id: rawUser.sub,
name: rawUser.name || rawUser.preferred_username || currentUser?.name || 'User',
email: rawUser.email || currentUser?.email || '',
role: (rawUser.roles?.includes('admin') || currentUser?.role === 'admin') ? 'admin' : 'user',
});
}
await updateLastVerifiedAt(sessionId);
return true;
}
log?.info('Keycloak UserInfo rejected. Attempting refresh token...');
} catch (err) {
log?.warn({ err }, 'Network error querying Keycloak UserInfo');
}
}
// 2. Refresh with refresh_token
if (!tokens.refresh_token) {
await deleteSession(sessionId);
return false;
}
try {
const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: clientId,
});
if (clientSecret) body.append('client_secret', clientSecret);
const refreshRes = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!refreshRes.ok) {
log?.warn('Session was revoked or deleted in Keycloak. Purging local session.');
await deleteSession(sessionId);
return false;
}
const rawNewTokens = await refreshRes.json() as any;
const newTokens: KeycloakTokens = {
access_token: rawNewTokens.access_token,
refresh_token: rawNewTokens.refresh_token || tokens.refresh_token,
id_token: rawNewTokens.id_token || tokens.id_token,
expires_at: Date.now() + ((rawNewTokens.expires_in || 300) * 1000),
};
await updateSessionTokens(sessionId, newTokens);
await updateLastVerifiedAt(sessionId);
return true;
} catch (err) {
log?.error({ err }, 'Critical error refreshing Keycloak session');
await deleteSession(sessionId);
return false;
}
}
/**
* Terminates session: Backchannel server call + RFC 7009 Revoke + Frontchannel URL
*/
export async function revokeKeycloakSession(
session: Session,
log?: FastifyBaseLogger
): Promise<string> {
const { keycloakUrl, realm, clientId, clientSecret, frontendUrl } = getKeycloakConfig();
let logoutUrl = `${frontendUrl}/login`;
if (!keycloakUrl) return logoutUrl;
const sessionId = session.get('session_id');
const stored = sessionId ? await getSession(sessionId) : null;
const tokens = stored?.tokens;
// 1. Direct backchannel logout
if (tokens?.refresh_token) {
try {
const backchannelEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/logout`;
const body = new URLSearchParams({
client_id: clientId,
refresh_token: tokens.refresh_token,
});
if (clientSecret) body.append('client_secret', clientSecret);
await fetch(backchannelEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
log?.info('Keycloak Backchannel logout executed');
} catch (err) {
log?.warn({ err }, 'Keycloak backchannel logout request failed');
}
// 2. RFC 7009 Token revocation
try {
const revokeEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/revoke`;
const revokeBody = new URLSearchParams({
client_id: clientId,
token: tokens.refresh_token,
token_type_hint: 'refresh_token',
});
if (clientSecret) revokeBody.append('client_secret', clientSecret);
await fetch(revokeEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: revokeBody.toString(),
});
} catch {}
}
// Purge local server session
if (sessionId) await deleteSession(sessionId);
// 3. Build RP-Initiated frontchannel logout URL
const logoutParams = new URLSearchParams();
if (tokens?.id_token) logoutParams.append('id_token_hint', tokens.id_token);
logoutParams.append('client_id', clientId);
logoutParams.append('post_logout_redirect_uri', `${frontendUrl}/login`);
return `${keycloakUrl}/realms/${realm}/protocol/openid-connect/logout?${logoutParams.toString()}`;
}
4.5. Auth Routes (routes/auth.ts)
import { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { UserInfo } from '../plugins/session.js';
import {
verifyOrRefreshKeycloakSession,
revokeKeycloakSession,
KeycloakTokens,
getKeycloakConfig
} from '../services/keycloakService.js';
import { saveSession } from '../services/sessionStore.js';
export async function authRoutes(fastify: FastifyInstance) {
const { keycloakUrl, realm, clientId, clientSecret, redirectUri, frontendUrl } = getKeycloakConfig();
const isMockMode = !keycloakUrl;
// 1. Initialize OIDC flow with PKCE (RFC 7636)
fastify.get('/login', async (request, reply) => {
if (isMockMode) {
const state = crypto.randomBytes(16).toString('hex');
request.session.set('oidc_state', state);
return reply.redirect(`/api/auth/callback?code=mock_code&state=${state}`);
}
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
request.session.set('oidc_state', state);
request.session.set('oidc_nonce', nonce);
request.session.set('oidc_code_verifier', codeVerifier);
const authUrl = new URL(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth`);
authUrl.searchParams.append('client_id', clientId);
authUrl.searchParams.append('redirect_uri', redirectUri);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('scope', 'openid email profile');
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('nonce', nonce);
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('prompt', 'login');
return reply.redirect(authUrl.toString());
});
// 2. OIDC Callback: Exchange code and establish session
fastify.get('/callback', async (request, reply) => {
const { code, state, error, error_description } = request.query as any;
if (error) {
return reply.code(400).send({ error: 'Auth Error', message: error_description || error });
}
const savedState = request.session.get('oidc_state');
const codeVerifier = request.session.get('oidc_code_verifier');
if (savedState && state && state !== savedState) {
return reply.code(400).send({ error: 'Bad Request', message: 'Invalid state parameter.' });
}
let userInfo: UserInfo;
let tokens: KeycloakTokens;
if (isMockMode) {
userInfo = { id: 'user_001', name: 'Admin Demo', email: 'admin@example.com', role: 'admin' };
tokens = { access_token: 'mock_token', refresh_token: 'mock_refresh', expires_at: Date.now() + 3600000 };
} else {
const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: code || '',
redirect_uri: redirectUri,
client_id: clientId,
});
if (clientSecret) body.append('client_secret', clientSecret);
if (codeVerifier) body.append('code_verifier', codeVerifier);
const tokenRes = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!tokenRes.ok) {
throw new Error(`Failed to exchange Keycloak token (${tokenRes.status})`);
}
const rawTokens = await tokenRes.json() as any;
tokens = {
access_token: rawTokens.access_token,
refresh_token: rawTokens.refresh_token,
id_token: rawTokens.id_token,
expires_at: Date.now() + ((rawTokens.expires_in || 300) * 1000),
};
const userInfoRes = await fetch(`${keycloakUrl}/realms/${realm}/protocol/openid-connect/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const rawUser = await userInfoRes.json() as any;
userInfo = {
id: rawUser.sub,
name: rawUser.name || rawUser.preferred_username || 'User',
email: rawUser.email || '',
role: rawUser.roles?.includes('admin') ? 'admin' : 'user',
};
}
// Save session in server-side session store
const sessionId = crypto.randomBytes(32).toString('hex');
await saveSession(sessionId, userInfo, tokens);
// Generate CSRF token
const csrfToken = crypto.randomBytes(32).toString('hex');
request.session.set('session_id', sessionId);
request.session.set('user', userInfo);
request.session.set('csrf_token', csrfToken);
return reply.redirect(frontendUrl);
});
// 3. Session status check endpoint (/me)
fastify.get('/me', async (request, reply) => {
const user = request.session.get('user');
const csrfToken = request.session.get('csrf_token');
if (!user) {
return reply.code(401).send({ authenticated: false, message: 'Unauthenticated.' });
}
// Active verification with Keycloak
if (keycloakUrl) {
const isSessionActive = await verifyOrRefreshKeycloakSession(request.session, request.log);
if (!isSessionActive) {
request.session.delete();
return reply.code(401).send({
authenticated: false,
code: 'SESSION_REVOKED',
message: 'Keycloak session was expired or revoked.'
});
}
}
return {
authenticated: true,
user: request.session.get('user'),
csrfToken: request.session.get('csrf_token') || csrfToken,
};
});
// 4. Universal logout endpoint (POST & GET)
const handleLogout = async (request: any, reply: any) => {
const logoutUrl = await revokeKeycloakSession(request.session, request.log);
request.session.delete();
return { success: true, logoutUrl };
};
fastify.post('/logout', async (request, reply) => handleLogout(request, reply));
fastify.get('/logout', async (request, reply) => {
const { logoutUrl } = await handleLogout(request, reply);
return reply.redirect(logoutUrl);
});
}
4.6. Main Fastify Server & Anti-CSRF Hook (server.ts)
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import crypto from 'crypto';
import sessionPlugin from './plugins/session.js';
import { authRoutes } from './routes/auth.js';
import { verifyOrRefreshKeycloakSession } from './services/keycloakService.js';
const isProduction = process.env.NODE_ENV === 'production';
const server = Fastify({
trustProxy: true, // Crucial for Traefik / Nginx (HTTPS, X-Forwarded-Host)
logger: isProduction ? true : { level: 'info' },
});
// TOLERATE EMPTY JSON BODIES ON POST (avoids FST_ERR_CTP_EMPTY_JSON_BODY)
server.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
if (!body || (typeof body === 'string' && body.trim() === '')) {
return done(null, {});
}
try {
const json = JSON.parse(body as string);
done(null, json);
} catch (err: any) {
err.statusCode = 400;
done(err, undefined);
}
});
await server.register(cors, {
origin: ['https://app.example.com', 'http://localhost:5173'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
await server.register(cookie);
const sessionSecret = crypto.createHash('sha256').update(process.env.SESSION_SECRET || 'fallback_secret_32bytes').digest();
await server.register(sessionPlugin, {
cookieName: 'app_session',
secret: sessionSecret,
maxAge: 7 * 24 * 60 * 60,
});
// GLOBAL BFF SECURITY GUARD HOOK
server.addHook('preHandler', async (request, reply) => {
const rawUrl = request.raw.url || request.url;
const path = rawUrl.split('?')[0];
const method = request.method;
const isPublic =
path === '/api/health' ||
path === '/api/auth/login' ||
path === '/api/auth/callback' ||
path === '/api/auth/me' ||
path === '/api/auth/logout';
// 1. Skip validation for public routes
if (isPublic || !isProduction) return;
// 2. Check local session for private routes
const user = request.session?.get('user');
if (!user) {
return reply.code(401).send({ error: 'Unauthorized', code: 'AUTH_REQUIRED' });
}
// 3. Actively validate Keycloak session
const sessionId = request.session?.get('session_id');
if (process.env.KEYCLOAK_URL && sessionId) {
const isAlive = await verifyOrRefreshKeycloakSession(request.session, request.log);
if (!isAlive) {
request.session.delete();
return reply.code(401).send({ error: 'Unauthorized', code: 'KEYCLOAK_SESSION_EXPIRED' });
}
}
// 4. Strict Anti-CSRF verification on mutations (POST, PUT, PATCH, DELETE)
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
const sessionCsrf = request.session.get('csrf_token');
const headerCsrf = request.headers['x-csrf-token'];
if (!sessionCsrf || !headerCsrf || sessionCsrf !== headerCsrf) {
return reply.code(403).send({ error: 'Forbidden', code: 'CSRF_INVALID' });
}
}
});
// Register routes
await server.register(authRoutes, { prefix: '/api/auth' });
await server.listen({ port: 3000, host: '0.0.0.0' });
5. Client SPA Implementation (Vue 3 / React)
5.1. Authentication Composable (composables/useAuth.ts)
import { ref } from 'vue';
export interface UserProfile {
id: string;
name: string;
email: string;
role: string;
}
const user = ref<UserProfile | null>(null);
const isAuthenticated = ref<boolean>(false);
const csrfToken = ref<string>('');
const loading = ref<boolean>(true);
export function useAuth() {
/**
* Check active session against BFF (actively validated against Keycloak)
*/
async function checkAuth(): Promise<boolean> {
loading.value = true;
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
if (data.authenticated && data.user) {
user.value = data.user;
csrfToken.value = data.csrfToken || '';
isAuthenticated.value = true;
return true;
}
}
user.value = null;
csrfToken.value = '';
isAuthenticated.value = false;
return false;
} catch {
user.value = null;
csrfToken.value = '';
isAuthenticated.value = false;
return false;
} finally {
loading.value = false;
}
}
function login() {
window.location.href = '/api/auth/login';
}
/**
* Secure logout: local clear + server revocation + SSO redirect
*/
async function logout() {
try {
const res = await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken.value,
},
body: JSON.stringify({}), // Explicit empty body
credentials: 'include',
});
user.value = null;
isAuthenticated.value = false;
csrfToken.value = '';
if (res.ok) {
const data = await res.json();
if (data.logoutUrl) {
window.location.href = data.logoutUrl;
return;
}
}
window.location.href = '/login';
} catch {
user.value = null;
isAuthenticated.value = false;
csrfToken.value = '';
window.location.href = '/login';
}
}
/**
* Secure fetch wrapper with automatic credentials and CSRF injection
*/
async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers || {});
if (init.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(init.method.toUpperCase())) {
if (csrfToken.value) {
headers.set('X-CSRF-Token', csrfToken.value);
}
}
const response = await fetch(input, { ...init, headers, credentials: 'include' });
if (response.status === 401) {
isAuthenticated.value = false;
user.value = null;
csrfToken.value = '';
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return response;
}
return {
user,
isAuthenticated,
csrfToken,
loading,
checkAuth,
login,
logout,
apiFetch,
};
}
5.2. Router Navigation Guard (router/index.ts)
import { createRouter, createWebHistory } from 'vue-router';
import { useAuth } from '../composables/useAuth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
{ path: '/dashboard', component: () => import('../views/DashboardView.vue') },
{ path: '/', redirect: '/dashboard' },
],
});
router.beforeEach(async (to, _from, next) => {
const { checkAuth } = useAuth();
if (to.meta.public) {
if (to.path === '/login') {
const isAuthed = await checkAuth();
if (isAuthed) return next({ path: '/dashboard' });
}
return next();
}
// Active validation: if Keycloak session was terminated, user is instantly redirected to /login
const isAuthed = await checkAuth();
if (!isAuthed) {
return next({ path: '/login', query: { redirect: to.fullPath } });
}
next();
});
export default router;
6. Security Scenarios Diagrams
Scenario 1: Administrative Revocation from Keycloak Admin Console
Keycloak Admin Browser Client Fastify BFF Keycloak IAM
│ │ │ │
├─ Deletes user session ───┼──────────────────────────┼───────────────────────►│ (Session destroyed)
│ │ │ │
│ (Page refresh or │ │
│ navigation click) │ │
│ │ │ │
│ ├─ GET /api/auth/me ──────►│ │
│ │ [Cookie aivo_session] ├─ GET /userinfo ───────►│
│ │ │ Bearer <access_token> │
│ │ │ │
│ │ │◄─ 401 Unauthorized ────┤ (Token invalid)
│ │ │ │
│ │ ├─ POST /token (refresh)►│
│ │ │ grant_type=refresh │
│ │ │ │
│ │ │◄─ 400 Bad Request ─────┤ (invalid_grant:
│ │ │ │ Session not active)
│ │ ├─ request.session.delete()
│ │ ├─ deleteSession(id) │
│ │ │ │
│ │◄─ 401 Unauthorized ──────┤ │
│ │ Set-Cookie: Max-Age=0 │ │
│ │ │ │
│ (Cookie purged + │ │
│ local state reset) │ │
│ │ │ │
│ ├─ Redirect to /login ─────┤ │
Scenario 2: User-Initiated Logout (Logout Button)
End User Browser Client Fastify BFF Keycloak IAM
│ │ │ │
├─ Clicks Logout ──────────►│ │ │
│ ├─ POST /api/auth/logout ──►│ │
│ │ Body: {} │ │
│ │ [Cookie aivo_session] ├─ POST /logout ────────►│ (Session revoked)
│ │ │ (Direct Backchannel) │
│ │ ├─ POST /revoke ────────►│ (Refresh revoked)
│ │ ├─ deleteSession(id) │
│ │ ├─ request.session.delete()
│ │ │ │
│ │◄─ 200 OK ─────────────────┤ │
│ │ Set-Cookie: Max-Age=0 │ │
│ │ { logoutUrl: "..." } │ │
│ │ │ │
│ (Purge client cookie) │ │
│ │ │ │
│ ├─ Browser Redirect ────────────────────────────────►│ (Clear SSO cookies)
│ │ to OIDC logoutUrl │ │
│ │ │ │
│ │◄─ 302 Redirect to /login ──────────────────────────┤
│ │ │ │
│ (Renders LoginView) │ │
7. Production Deployment Checklist
| Checkpoint | Recommended Setting | Rationale |
|---|---|---|
| Reverse Proxy (Traefik / Nginx) | trustProxy: true in Fastify | Enables proper HTTPS protocol and X-Forwarded-Host resolution. |
| Cookie Domain | .example.com (with leading dot) | Allows session cookie sharing across app.example.com and api.example.com. |
| SameSite Cookie | SameSite: Lax | Prevents third-party cookie blocking in Safari (ITP) while transmitting cookies on post-login redirects. |
| Cookie Size | Strictly < 1 KB | Prevents silent cookie drops across browsers (RFC 6265 limit: 4 KB). |
| Keycloak Client Settings | Fill Valid Post Logout Redirect URIs | Prevents Invalid parameter: post_logout_redirect_uri errors upon logout redirection. |
| Fastify Body Parser | Tolerate empty bodies on application/json | Prevents Fastify FST_ERR_CTP_EMPTY_JSON_BODY 400 errors on logout requests. |
| Anti-CSRF Protection | Synchronized X-CSRF-Token header | Critical on mutating requests (POST, PUT, DELETE) using credentials with cookies. |
8. Open-Source Starter Repository: Run the Demo in 1 Minute
To help you test this architecture in real-world conditions without rewriting the full stack, a turnkey boilerplate repository is available:
👉 GitHub Repository: pure-bff-token-handler-starter
It orchestrates the complete stack with a single docker compose up:
- PostgreSQL 16 (sovereign session persistence).
- Keycloak 24 (with auto-imported
pure-bff-demorealm and pre-configureddemo/demoandadmin/adminaccounts). - Fastify BFF Server (Token Handler, AES-256-GCM encryption, strict anti-CSRF hook).
- Vue 3 + Vite SPA (zero tokens in browser, fine-grained session state).
Going further?
This starter is natively compatible with Authelia, Authentik, Okta, and Microsoft Entra ID thanks to standard OIDC Discovery 1.0 support.
Subscribe to our technical updates on our AIVO Blog or our AIVO Tech Insights for upcoming architecture deep dives on digital sovereignty and Zero-Trust.
Nicolas BAUD
Solutions Architect and Fractional CTO with over 15 years of experience, Nicolas BAUD assists organizations in securing their IT infrastructure, deploying sovereign AI architectures, and implementing software eco-design.