DEV Community

Nicolas Baud
Nicolas Baud

Posted on Originally published at aivo.fr on

Pure BFF & Token Handler Architecture: The Ultimate OIDC / OAuth 2.0 Security Guide for SPAs

Pure BFF & Token Handler Architecture: The Ultimate OIDC / OAuth 2.0 Security Guide for SPAs

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 localStorage or sessionStorage: 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

  1. Zero tokens in the browser: The frontend client never sees or handles raw JWTs (access_token, refresh_token, id_token).
  2. Encrypted HttpOnly & SameSite session cookie: The browser only holds an encrypted session cookie inaccessible to JavaScript (httpOnly: true, secure: true, sameSite: 'lax').
  3. 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                             │
└────────────────────────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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_token JWT often weighs between 1,000 and 1,500 bytes.
  • A refresh_token weighs between 900 and 1,200 bytes.
  • An id_token weighs 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 a 401 Unauthorized because 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 the csrf_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 ms lookup) backed by a PostgreSQL database (user_sessions table) 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);
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
        });
      }
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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()}`;
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 ─────┤                        │
Enter fullscreen mode Exit fullscreen mode

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)                  │                        │
Enter fullscreen mode Exit fullscreen mode

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-demo realm and pre-configured demo/demo and admin/admin accounts).
  • 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)