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).
💬 How do you currently handle OAuth 2.0 / OIDC session security in your single-page applications ?
Drop a comment below with your thoughts, or feel free to check out the GitHub repo and share your feedback!

Top comments (0)