Authentication Architecture for SPAs: Production Deep Dive
Introduction
Single Page Application authentication is fundamentally different from traditional server-rendered applications. The stateless nature of SPAs, combined with browser security constraints and modern threat landscapes, creates architectural challenges that naive implementations catastrophically fail to address.
The traditional mental model—"just store a JWT in localStorage"—leads to security vulnerabilities that are trivially exploitable:
- Token theft via XSS: Any JavaScript running in your origin can read localStorage
- Session fixation: Improper token rotation allows persistent unauthorized access
- Token replay: Stolen tokens can be used indefinitely without detection
- Cross-tab inconsistency: Auth state diverges across browser tabs
- Silent refresh failures: Token expiration causes jarring UX interruptions
Production constraints at scale (50M+ MAU):
GIF via GIPHY
Authentication requirements:
- 99.99% availability (< 52 minutes downtime/year)
- < 100ms auth latency (P95)
- Support for 10+ identity providers (OAuth, SAML, OIDC)
- Multi-device session management
- Step-up authentication for sensitive operations
- Regulatory compliance (SOC2, GDPR, HIPAA)
Threat landscape:
- 500k+ credential stuffing attempts/day
- 10k+ account takeover attempts/day
- Sophisticated phishing campaigns
- Token theft via malicious browser extensions
- Session hijacking via network attacks
This article provides a comprehensive architectural guide for building secure, scalable authentication systems for SPAs—covering token strategies, session management, multi-provider federation, and production incident patterns.
Authentication Flow Architecture
OAuth 2.0 + OIDC for SPAs
Authorization Code Flow with PKCE is the recommended pattern for SPAs:
┌─────────┐ ┌─────────────┐
│ SPA │ │ Auth │
│(Browser)│ │ Server │
└────┬────┘ └──────┬──────┘
│ │
│ 1. Generate code_verifier + code_challenge
│ (PKCE) │
│ │
│ 2. Redirect to /authorize │
│ ?response_type=code │
│ &client_id=... │
│ &redirect_uri=... │
│ &scope=openid profile email │
│ &code_challenge=... │
│ &code_challenge_method=S256 │
│ &state=... │
│─────────────────────────────────────────▶│
│ │
│ 3. User authenticates │
│ │
│ 4. Redirect to callback │
│ ?code=...&state=... │
│◀─────────────────────────────────────────│
│ │
│ 5. Exchange code for tokens │
│ POST /token │
│ grant_type=authorization_code │
│ &code=... │
│ &code_verifier=... │
│─────────────────────────────────────────▶│
│ │
│ 6. Tokens returned │
│ {access_token, id_token, refresh_token}
│◀─────────────────────────────────────────│
│ │
PKCE implementation:
// PKCE utilities
class PKCEManager {
async generateChallenge(): Promise<{
codeVerifier: string;
codeChallenge: string;
}> {
// Generate cryptographically random verifier
const verifierBytes = new Uint8Array(32);
crypto.getRandomValues(verifierBytes);
const codeVerifier = this.base64UrlEncode(verifierBytes);
// Generate challenge (SHA-256 hash of verifier)
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = this.base64UrlEncode(new Uint8Array(digest));
return { codeVerifier, codeChallenge };
}
private base64UrlEncode(bytes: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...bytes));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
}
// Auth flow implementation
class AuthService {
private pkce = new PKCEManager();
async initiateLogin(returnTo?: string): Promise<void> {
const { codeVerifier, codeChallenge } = await this.pkce.generateChallenge();
// Store verifier securely (sessionStorage, not localStorage)
sessionStorage.setItem('pkce_verifier', codeVerifier);
// Generate state for CSRF protection
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('return_to', returnTo || '/');
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: AUTH_CONFIG.clientId,
redirect_uri: AUTH_CONFIG.redirectUri,
scope: 'openid profile email offline_access',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
// Prompt for login (no silent auth)
prompt: 'login',
});
window.location.href = `${AUTH_CONFIG.authorizationEndpoint}?${params}`;
}
async handleCallback(): Promise<AuthResult> {
const params = new URLSearchParams(window.location.search);
// Validate state (CSRF protection)
const state = params.get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (!state || state !== storedState) {
throw new AuthError('STATE_MISMATCH', 'Invalid state parameter');
}
// Check for errors
const error = params.get('error');
if (error) {
throw new AuthError(error, params.get('error_description') || '');
}
// Get authorization code
const code = params.get('code');
if (!code) {
throw new AuthError('NO_CODE', 'No authorization code received');
}
// Get stored verifier
const codeVerifier = sessionStorage.getItem('pkce_verifier');
if (!codeVerifier) {
throw new AuthError('NO_VERIFIER', 'PKCE verifier not found');
}
// Exchange code for tokens
const tokens = await this.exchangeCode(code, codeVerifier);
// Clean up
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
// Get return URL
const returnTo = sessionStorage.getItem('return_to') || '/';
sessionStorage.removeItem('return_to');
return { tokens, returnTo };
}
private async exchangeCode(
code: string,
codeVerifier: string
): Promise<TokenSet> {
const response = await fetch(AUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: AUTH_CONFIG.clientId,
code,
code_verifier: codeVerifier,
redirect_uri: AUTH_CONFIG.redirectUri,
}),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.error_description);
}
return response.json();
}
}
Backend-for-Frontend (BFF) Pattern
GIF via GIPHY
Problem with pure SPA auth: Tokens exposed to JavaScript are vulnerable to XSS.
Solution: BFF proxy handles tokens server-side:
┌─────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SPA │ │
│ │ │ │
│ │ - No access tokens in JavaScript │ │
│ │ - HttpOnly session cookie only │ │
│ │ - All API calls through BFF │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ HttpOnly Cookie │
│ │ │
└──────────────────────────────┼───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ BFF (Same Origin) │
│ │
│ - Handles OAuth flow │
│ - Stores tokens in encrypted session │
│ - Proxies API calls with access token │
│ - Handles token refresh │
│ │
└─────────────────────────────────────────────────────────────────┘
│
Access Token
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ API Server │
└─────────────────────────────────────────────────────────────────┘
BFF implementation:
// BFF server (Express)
import express from 'express';
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const app = express();
const redis = createClient({ url: REDIS_URL });
// Session configuration
app.use(session({
store: new RedisStore({ client: redis }),
name: '__Host-session', // Host-prefixed for additional security
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/',
},
}));
// OAuth callback handler
app.get('/auth/callback', async (req, res) => {
try {
const { code, state } = req.query;
// Validate state
if (state !== req.session.oauthState) {
return res.redirect('/login?error=state_mismatch');
}
// Exchange code for tokens
const tokens = await exchangeAuthorizationCode(
code as string,
req.session.codeVerifier
);
// Validate ID token
const idTokenClaims = await validateIdToken(tokens.id_token);
// Store tokens in session (encrypted at rest in Redis)
req.session.tokens = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
};
req.session.user = {
id: idTokenClaims.sub,
email: idTokenClaims.email,
name: idTokenClaims.name,
};
// Clean up OAuth state
delete req.session.oauthState;
delete req.session.codeVerifier;
// Redirect to original destination
const returnTo = req.session.returnTo || '/';
delete req.session.returnTo;
res.redirect(returnTo);
} catch (error) {
console.error('Auth callback error:', error);
res.redirect('/login?error=auth_failed');
}
});
// API proxy with token injection
app.use('/api', async (req, res, next) => {
if (!req.session.tokens) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Check token expiration
if (Date.now() >= req.session.tokens.expiresAt - 60000) {
try {
// Refresh tokens
const newTokens = await refreshAccessToken(
req.session.tokens.refreshToken
);
req.session.tokens = {
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token || req.session.tokens.refreshToken,
expiresAt: Date.now() + (newTokens.expires_in * 1000),
};
} catch (error) {
// Refresh failed, require re-authentication
req.session.destroy(() => {});
return res.status(401).json({ error: 'Session expired' });
}
}
// Proxy request to API
try {
const apiResponse = await fetch(`${API_BASE_URL}${req.path}`, {
method: req.method,
headers: {
'Authorization': `Bearer ${req.session.tokens.accessToken}`,
'Content-Type': 'application/json',
'X-Request-ID': req.id,
},
body: ['POST', 'PUT', 'PATCH'].includes(req.method)
? JSON.stringify(req.body)
: undefined,
});
const data = await apiResponse.json();
res.status(apiResponse.status).json(data);
} catch (error) {
console.error('API proxy error:', error);
res.status(502).json({ error: 'API unavailable' });
}
});
// User info endpoint (for SPA)
app.get('/auth/me', (req, res) => {
if (!req.session.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
res.json({ user: req.session.user });
});
// Logout
app.post('/auth/logout', async (req, res) => {
if (req.session.tokens?.refreshToken) {
// Revoke refresh token at IdP
try {
await revokeToken(req.session.tokens.refreshToken);
} catch (error) {
console.error('Token revocation failed:', error);
}
}
req.session.destroy((err) => {
if (err) {
console.error('Session destruction failed:', err);
}
res.clearCookie('__Host-session');
res.json({ success: true });
});
});
Token Strategy Architecture
Access Token Lifetime Matrix
| Token Type | Lifetime | Storage | Use Case |
|---|---|---|---|
| Access Token | 5-15 minutes | Memory/BFF session | API authorization |
| Refresh Token | 7-30 days | HttpOnly cookie/BFF | Token renewal |
| ID Token | 1 hour | Memory (verification) | User identity |
| Session Cookie | 24 hours | HttpOnly cookie | Session binding |
Silent Token Refresh
Challenge: Refresh tokens without user interaction while maintaining security.
// Token refresh manager (SPA without BFF)
class TokenManager {
private accessToken: string | null = null;
private refreshTimeout: number | null = null;
private refreshPromise: Promise<string> | null = null;
async getAccessToken(): Promise<string> {
// If refresh is in progress, wait for it
if (this.refreshPromise) {
return this.refreshPromise;
}
if (this.accessToken && !this.isTokenExpiringSoon()) {
return this.accessToken;
}
// Trigger refresh
return this.refresh();
}
private isTokenExpiringSoon(): boolean {
if (!this.accessToken) return true;
try {
const payload = JSON.parse(atob(this.accessToken.split('.')[1]));
const expiresAt = payload.exp * 1000;
// Refresh if expiring within 60 seconds
return Date.now() >= expiresAt - 60000;
} catch {
return true;
}
}
private async refresh(): Promise<string> {
// Prevent concurrent refresh attempts
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.performRefresh();
try {
const token = await this.refreshPromise;
this.accessToken = token;
this.scheduleRefresh(token);
return token;
} finally {
this.refreshPromise = null;
}
}
private async performRefresh(): Promise<string> {
// Option 1: Silent iframe refresh (for same-domain IdP)
try {
return await this.silentIframeRefresh();
} catch (error) {
console.warn('Silent refresh failed, trying refresh token');
}
// Option 2: Refresh token rotation
const refreshToken = await this.getRefreshToken();
if (refreshToken) {
try {
return await this.refreshWithToken(refreshToken);
} catch (error) {
console.warn('Refresh token failed');
throw new AuthError('REFRESH_FAILED', 'Unable to refresh session');
}
}
throw new AuthError('NO_SESSION', 'No valid session');
}
private silentIframeRefresh(): Promise<string> {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Silent refresh timeout'));
}, 10000);
const cleanup = () => {
clearTimeout(timeout);
window.removeEventListener('message', messageHandler);
iframe.remove();
};
const messageHandler = (event: MessageEvent) => {
if (event.origin !== AUTH_CONFIG.issuer) return;
if (event.data.type === 'auth_success') {
cleanup();
resolve(event.data.accessToken);
} else if (event.data.type === 'auth_error') {
cleanup();
reject(new Error(event.data.error));
}
};
window.addEventListener('message', messageHandler);
// Load silent auth endpoint
const params = new URLSearchParams({
response_type: 'token',
client_id: AUTH_CONFIG.clientId,
redirect_uri: AUTH_CONFIG.silentRedirectUri,
scope: 'openid profile email',
prompt: 'none', // Silent auth
});
iframe.src = `${AUTH_CONFIG.authorizationEndpoint}?${params}`;
document.body.appendChild(iframe);
});
}
private scheduleRefresh(token: string): void {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
}
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresAt = payload.exp * 1000;
const refreshAt = expiresAt - 120000; // Refresh 2 minutes before expiry
const delay = Math.max(0, refreshAt - Date.now());
this.refreshTimeout = window.setTimeout(() => {
this.refresh().catch(console.error);
}, delay);
} catch (error) {
console.error('Failed to schedule refresh:', error);
}
}
}
GIF via GIPHY
Refresh Token Rotation
Security pattern: Issue new refresh token with each use, invalidating the old one:
// Server-side refresh token rotation
class RefreshTokenService {
private redis: RedisClient;
async refreshTokens(refreshToken: string): Promise<TokenSet> {
// Decode refresh token
const payload = this.verifyRefreshToken(refreshToken);
// Check if token is in the valid token family
const tokenFamily = await this.redis.get(`token_family:${payload.family}`);
if (!tokenFamily) {
// Token family doesn't exist - suspicious
throw new AuthError('INVALID_TOKEN', 'Refresh token invalid');
}
const family = JSON.parse(tokenFamily);
// Check if this specific token has been used
if (family.usedTokens.includes(refreshToken)) {
// TOKEN REUSE DETECTED - possible theft
// Invalidate entire token family
await this.redis.del(`token_family:${payload.family}`);
// Alert security team
await this.alertTokenReuse(payload.sub, payload.family);
throw new AuthError('TOKEN_REUSED', 'Security violation detected');
}
// Mark current token as used
family.usedTokens.push(refreshToken);
// Generate new token pair
const newAccessToken = this.generateAccessToken(payload.sub);
const newRefreshToken = this.generateRefreshToken(payload.sub, payload.family);
// Update token family with new token
family.currentToken = newRefreshToken;
family.lastUsed = Date.now();
// Keep only last 5 used tokens (for short replay window)
family.usedTokens = family.usedTokens.slice(-5);
await this.redis.set(
`token_family:${payload.family}`,
JSON.stringify(family),
'EX',
30 * 24 * 60 * 60 // 30 days
);
return {
access_token: newAccessToken,
refresh_token: newRefreshToken,
expires_in: 900, // 15 minutes
};
}
private async alertTokenReuse(userId: string, familyId: string): Promise<void> {
// Log security event
console.error('SECURITY: Refresh token reuse detected', {
userId,
familyId,
timestamp: Date.now(),
});
// Notify security team
await this.notificationService.send({
channel: 'security-alerts',
severity: 'high',
message: `Potential token theft for user ${userId}`,
details: { familyId },
});
// Consider requiring re-authentication for user
await this.sessionService.invalidateUserSessions(userId);
}
}
Multi-Tab Session Synchronization
Cross-Tab Communication
Problem: Auth state changes (login/logout) must sync across all open tabs.
// Cross-tab auth state synchronization
class AuthStateSynchronizer {
private channel: BroadcastChannel;
private listeners: Set<(state: AuthState) => void> = new Set();
constructor() {
this.channel = new BroadcastChannel('auth_state');
this.channel.onmessage = this.handleMessage.bind(this);
// Also handle storage events (fallback for older browsers)
window.addEventListener('storage', this.handleStorageEvent.bind(this));
}
private handleMessage(event: MessageEvent): void {
const { type, payload } = event.data;
switch (type) {
case 'LOGIN':
this.notifyListeners({ isAuthenticated: true, user: payload.user });
break;
case 'LOGOUT':
this.notifyListeners({ isAuthenticated: false, user: null });
// Clear local state
this.clearLocalAuthState();
break;
case 'TOKEN_REFRESH':
// Another tab refreshed tokens
this.updateLocalTokens(payload.tokens);
break;
case 'SESSION_EXPIRED':
this.notifyListeners({ isAuthenticated: false, user: null });
this.clearLocalAuthState();
// Redirect to login
window.location.href = '/login?reason=session_expired';
break;
}
}
private handleStorageEvent(event: StorageEvent): void {
if (event.key === 'auth_state_change') {
try {
const data = JSON.parse(event.newValue || '{}');
this.handleMessage({ data } as MessageEvent);
} catch (error) {
console.error('Failed to parse auth state change:', error);
}
}
}
broadcastLogin(user: User): void {
this.channel.postMessage({ type: 'LOGIN', payload: { user } });
// Fallback for Safari
localStorage.setItem('auth_state_change', JSON.stringify({
type: 'LOGIN',
payload: { user },
timestamp: Date.now(),
}));
}
broadcastLogout(): void {
this.channel.postMessage({ type: 'LOGOUT' });
localStorage.setItem('auth_state_change', JSON.stringify({
type: 'LOGOUT',
timestamp: Date.now(),
}));
}
broadcastTokenRefresh(tokens: TokenSet): void {
this.channel.postMessage({ type: 'TOKEN_REFRESH', payload: { tokens } });
}
broadcastSessionExpired(): void {
this.channel.postMessage({ type: 'SESSION_EXPIRED' });
localStorage.setItem('auth_state_change', JSON.stringify({
type: 'SESSION_EXPIRED',
timestamp: Date.now(),
}));
}
subscribe(listener: (state: AuthState) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(state: AuthState): void {
this.listeners.forEach(listener => listener(state));
}
private clearLocalAuthState(): void {
// Clear in-memory tokens
tokenManager.clear();
// Clear any cached user data
queryClient.removeQueries(['user']);
}
private updateLocalTokens(tokens: TokenSet): void {
tokenManager.setTokens(tokens);
}
}
// Usage in React
const authSync = new AuthStateSynchronizer();
function useAuthSync() {
const [authState, setAuthState] = useState<AuthState | null>(null);
useEffect(() => {
return authSync.subscribe(setAuthState);
}, []);
return authState;
}
// In auth provider
function AuthProvider({ children }: { children: React.ReactNode }) {
const syncedState = useAuthSync();
useEffect(() => {
if (syncedState) {
if (!syncedState.isAuthenticated) {
// Another tab logged out
navigate('/login');
}
}
}, [syncedState]);
// ... rest of provider
}
Leader Election for Token Refresh
GIF via GIPHY
Problem: Multiple tabs refreshing tokens simultaneously causes race conditions.
// Leader election for token refresh
class RefreshLeaderElection {
private isLeader = false;
private channel: BroadcastChannel;
private heartbeatInterval: number | null = null;
private leaderTimeout: number | null = null;
private readonly HEARTBEAT_INTERVAL = 5000;
private readonly LEADER_TIMEOUT = 15000;
constructor() {
this.channel = new BroadcastChannel('refresh_leader');
this.channel.onmessage = this.handleMessage.bind(this);
// Try to become leader on initialization
this.requestLeadership();
// Re-elect on visibility change (tab becomes active)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !this.isLeader) {
this.requestLeadership();
}
});
}
private handleMessage(event: MessageEvent): void {
const { type, tabId, timestamp } = event.data;
switch (type) {
case 'LEADER_HEARTBEAT':
// Another tab is leader
if (tabId !== this.tabId) {
this.isLeader = false;
this.resetLeaderTimeout();
}
break;
case 'LEADER_CLAIM':
// Another tab claiming leadership
if (this.isLeader && tabId !== this.tabId) {
// Resolve conflict: lower tabId wins
if (tabId < this.tabId) {
this.isLeader = false;
this.stopHeartbeat();
} else {
// We keep leadership, send heartbeat
this.sendHeartbeat();
}
}
break;
case 'REFRESH_STARTED':
// Leader is refreshing tokens
this.lastRefreshTime = timestamp;
break;
case 'REFRESH_COMPLETE':
// Leader completed refresh
this.handleRefreshComplete(event.data.tokens);
break;
}
}
private tabId = crypto.randomUUID();
private lastRefreshTime = 0;
private requestLeadership(): void {
this.channel.postMessage({
type: 'LEADER_CLAIM',
tabId: this.tabId
});
// Wait for conflicts
setTimeout(() => {
if (!this.isLeader) {
this.checkLeaderTimeout();
}
}, 1000);
}
private checkLeaderTimeout(): void {
// If no heartbeat received, become leader
this.leaderTimeout = window.setTimeout(() => {
this.becomeLeader();
}, this.LEADER_TIMEOUT);
}
private resetLeaderTimeout(): void {
if (this.leaderTimeout) {
clearTimeout(this.leaderTimeout);
}
this.checkLeaderTimeout();
}
private becomeLeader(): void {
this.isLeader = true;
this.startHeartbeat();
console.log('This tab is now the refresh leader');
}
private startHeartbeat(): void {
this.sendHeartbeat();
this.heartbeatInterval = window.setInterval(() => {
this.sendHeartbeat();
}, this.HEARTBEAT_INTERVAL);
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private sendHeartbeat(): void {
this.channel.postMessage({
type: 'LEADER_HEARTBEAT',
tabId: this.tabId
});
}
async refreshTokens(): Promise<TokenSet> {
if (!this.isLeader) {
// Wait for leader to refresh
return this.waitForLeaderRefresh();
}
// Notify other tabs
this.channel.postMessage({
type: 'REFRESH_STARTED',
tabId: this.tabId,
timestamp: Date.now(),
});
try {
const tokens = await performTokenRefresh();
// Broadcast new tokens to other tabs
this.channel.postMessage({
type: 'REFRESH_COMPLETE',
tabId: this.tabId,
tokens,
});
return tokens;
} catch (error) {
this.channel.postMessage({
type: 'REFRESH_FAILED',
tabId: this.tabId,
error: error.message,
});
throw error;
}
}
private waitForLeaderRefresh(): Promise<TokenSet> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
// Leader failed, try to become leader
this.becomeLeader();
this.refreshTokens().then(resolve).catch(reject);
}, 10000);
const cleanup = () => {
clearTimeout(timeout);
this.channel.removeEventListener('message', handler);
};
const handler = (event: MessageEvent) => {
if (event.data.type === 'REFRESH_COMPLETE') {
cleanup();
resolve(event.data.tokens);
} else if (event.data.type === 'REFRESH_FAILED') {
cleanup();
reject(new Error(event.data.error));
}
};
this.channel.addEventListener('message', handler);
});
}
}
Multi-Provider Federation
Identity Provider Abstraction
// Identity provider interface
interface IdentityProvider {
name: string;
type: 'oauth2' | 'oidc' | 'saml';
initiateLogin(options: LoginOptions): Promise<void>;
handleCallback(params: URLSearchParams): Promise<AuthResult>;
refreshTokens(refreshToken: string): Promise<TokenSet>;
revokeTokens(tokens: TokenSet): Promise<void>;
getUserInfo(accessToken: string): Promise<UserInfo>;
}
// OIDC provider implementation
class OIDCProvider implements IdentityProvider {
name: string;
type = 'oidc' as const;
private config: OIDCConfig;
private discoveryDocument: OIDCDiscoveryDocument | null = null;
constructor(config: OIDCConfig) {
this.name = config.name;
this.config = config;
}
async initialize(): Promise<void> {
// Fetch OIDC discovery document
const response = await fetch(
`${this.config.issuer}/.well-known/openid-configuration`
);
this.discoveryDocument = await response.json();
}
async initiateLogin(options: LoginOptions): Promise<void> {
if (!this.discoveryDocument) {
await this.initialize();
}
const { codeVerifier, codeChallenge } = await generatePKCE();
sessionStorage.setItem(`pkce_${this.name}`, codeVerifier);
const state = crypto.randomUUID();
sessionStorage.setItem(`state_${this.name}`, state);
if (options.returnTo) {
sessionStorage.setItem('return_to', options.returnTo);
}
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scopes.join(' '),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
...(options.loginHint && { login_hint: options.loginHint }),
...(options.prompt && { prompt: options.prompt }),
});
window.location.href =
`${this.discoveryDocument!.authorization_endpoint}?${params}`;
}
async handleCallback(params: URLSearchParams): Promise<AuthResult> {
// Validate state
const state = params.get('state');
const storedState = sessionStorage.getItem(`state_${this.name}`);
if (!state || state !== storedState) {
throw new AuthError('STATE_MISMATCH');
}
// Handle errors
const error = params.get('error');
if (error) {
throw new AuthError(error, params.get('error_description') || undefined);
}
// Exchange code
const code = params.get('code');
const codeVerifier = sessionStorage.getItem(`pkce_${this.name}`);
if (!code || !codeVerifier) {
throw new AuthError('INVALID_CALLBACK');
}
const tokens = await this.exchangeCode(code, codeVerifier);
// Validate ID token
const idTokenClaims = await this.validateIdToken(tokens.id_token);
// Clean up
sessionStorage.removeItem(`pkce_${this.name}`);
sessionStorage.removeItem(`state_${this.name}`);
return {
tokens,
user: {
id: idTokenClaims.sub,
email: idTokenClaims.email,
name: idTokenClaims.name,
picture: idTokenClaims.picture,
},
returnTo: sessionStorage.getItem('return_to') || '/',
};
}
private async exchangeCode(
code: string,
codeVerifier: string
): Promise<TokenSet> {
const response = await fetch(this.discoveryDocument!.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.clientId,
code,
code_verifier: codeVerifier,
redirect_uri: this.config.redirectUri,
}),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(error.error, error.error_description);
}
return response.json();
}
private async validateIdToken(idToken: string): Promise<IdTokenClaims> {
// Decode header to get key ID
const [headerB64] = idToken.split('.');
const header = JSON.parse(atob(headerB64));
// Fetch JWKS
const jwksResponse = await fetch(this.discoveryDocument!.jwks_uri);
const jwks = await jwksResponse.json();
// Find matching key
const key = jwks.keys.find((k: JWK) => k.kid === header.kid);
if (!key) {
throw new AuthError('INVALID_TOKEN', 'Signing key not found');
}
// Verify signature
const cryptoKey = await crypto.subtle.importKey(
'jwk',
key,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify']
);
const [, payloadB64, signatureB64] = idToken.split('.');
const signatureData = this.base64UrlDecode(signatureB64);
const signedData = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const isValid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
cryptoKey,
signatureData,
signedData
);
if (!isValid) {
throw new AuthError('INVALID_TOKEN', 'Signature verification failed');
}
// Validate claims
const claims: IdTokenClaims = JSON.parse(atob(payloadB64));
// Check issuer
if (claims.iss !== this.config.issuer) {
throw new AuthError('INVALID_TOKEN', 'Invalid issuer');
}
// Check audience
if (claims.aud !== this.config.clientId) {
throw new AuthError('INVALID_TOKEN', 'Invalid audience');
}
// Check expiration
if (claims.exp * 1000 < Date.now()) {
throw new AuthError('INVALID_TOKEN', 'Token expired');
}
// Check not before
if (claims.nbf && claims.nbf * 1000 > Date.now()) {
throw new AuthError('INVALID_TOKEN', 'Token not yet valid');
}
return claims;
}
private base64UrlDecode(str: string): Uint8Array {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, c => c.charCodeAt(0));
}
}
// Federation manager
class FederationManager {
private providers: Map<string, IdentityProvider> = new Map();
registerProvider(provider: IdentityProvider): void {
this.providers.set(provider.name, provider);
}
getProvider(name: string): IdentityProvider {
const provider = this.providers.get(name);
if (!provider) {
throw new Error(`Unknown identity provider: ${name}`);
}
return provider;
}
async login(providerName: string, options: LoginOptions = {}): Promise<void> {
const provider = this.getProvider(providerName);
// Store provider name for callback handling
sessionStorage.setItem('auth_provider', providerName);
await provider.initiateLogin(options);
}
async handleCallback(): Promise<AuthResult> {
const providerName = sessionStorage.getItem('auth_provider');
if (!providerName) {
throw new AuthError('NO_PROVIDER', 'No auth provider in session');
}
const provider = this.getProvider(providerName);
const params = new URLSearchParams(window.location.search);
const result = await provider.handleCallback(params);
sessionStorage.removeItem('auth_provider');
return result;
}
}
// Usage
const federation = new FederationManager();
federation.registerProvider(new OIDCProvider({
name: 'google',
issuer: 'https://accounts.google.com',
clientId: GOOGLE_CLIENT_ID,
redirectUri: `${window.location.origin}/auth/callback`,
scopes: ['openid', 'profile', 'email'],
}));
federation.registerProvider(new OIDCProvider({
name: 'azure',
issuer: `https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0`,
clientId: AZURE_CLIENT_ID,
redirectUri: `${window.location.origin}/auth/callback`,
scopes: ['openid', 'profile', 'email'],
}));
// Login with specific provider
await federation.login('google');
await federation.login('azure', { loginHint: 'user@company.com' });
GIF via GIPHY
Session Management Architecture
Session Lifecycle
┌─────────────────────────────────────────────────────────────────┐
│ SESSION STATE MACHINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ Login ┌──────────┐ │
│ │ GUEST │──────────────▶│ ACTIVE │ │
│ └──────────┘ └────┬─────┘ │
│ ▲ │ │
│ │ │ Idle timeout │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │ IDLE │ │
│ │ └────┬─────┘ │
│ │ │ │
│ │ ┌────────────┼────────────┐ │
│ │ │ │ │ │
│ │ Activity Abs. timeout Logout │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ ACTIVE │ │ EXPIRED │ │ ENDED │ │
│ │ └──────────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └────────────────────────┴────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Session Manager Implementation
// Comprehensive session manager
class SessionManager {
private state: SessionState = 'guest';
private idleTimer: number | null = null;
private absoluteTimer: number | null = null;
private activityThrottle: number | null = null;
private readonly IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
private readonly ABSOLUTE_TIMEOUT = 12 * 60 * 60 * 1000; // 12 hours
private readonly ACTIVITY_THROTTLE = 60 * 1000; // 1 minute
private listeners: Set<(state: SessionState) => void> = new Set();
constructor() {
this.setupActivityListeners();
this.setupVisibilityListener();
this.restoreSession();
}
private setupActivityListeners(): void {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
document.addEventListener(event, () => this.onActivity(), { passive: true });
});
}
private setupVisibilityListener(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.checkSessionValidity();
}
});
}
private onActivity(): void {
if (this.state !== 'active' && this.state !== 'idle') return;
// Throttle activity updates
if (this.activityThrottle) return;
this.activityThrottle = window.setTimeout(() => {
this.activityThrottle = null;
}, this.ACTIVITY_THROTTLE);
// Reset idle timer
this.resetIdleTimer();
// Transition from idle to active
if (this.state === 'idle') {
this.transition('active');
}
// Update last activity timestamp
localStorage.setItem('last_activity', Date.now().toString());
}
private resetIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
this.idleTimer = window.setTimeout(() => {
if (this.state === 'active') {
this.transition('idle');
this.showIdleWarning();
}
}, this.IDLE_TIMEOUT);
}
private showIdleWarning(): void {
// Show modal warning user of impending session expiration
const warningDuration = 5 * 60 * 1000; // 5 minutes to respond
const modal = createIdleWarningModal({
onContinue: () => {
this.transition('active');
this.resetIdleTimer();
},
onLogout: () => {
this.logout();
},
timeout: warningDuration,
});
// Auto-logout after warning period
setTimeout(() => {
if (this.state === 'idle') {
modal.close();
this.transition('expired');
this.logout('idle_timeout');
}
}, warningDuration);
}
private checkSessionValidity(): void {
const sessionStart = localStorage.getItem('session_start');
const lastActivity = localStorage.getItem('last_activity');
if (!sessionStart) {
this.transition('guest');
return;
}
const now = Date.now();
const sessionAge = now - parseInt(sessionStart);
const idleTime = now - parseInt(lastActivity || sessionStart);
// Check absolute timeout
if (sessionAge >= this.ABSOLUTE_TIMEOUT) {
this.transition('expired');
this.logout('absolute_timeout');
return;
}
// Check idle timeout
if (idleTime >= this.IDLE_TIMEOUT) {
this.transition('idle');
this.showIdleWarning();
return;
}
// Session valid
this.transition('active');
this.resetIdleTimer();
}
async login(tokens: TokenSet, user: User): Promise<void> {
// Store session start time
localStorage.setItem('session_start', Date.now().toString());
localStorage.setItem('last_activity', Date.now().toString());
// Set absolute timeout
this.absoluteTimer = window.setTimeout(() => {
this.transition('expired');
this.logout('absolute_timeout');
}, this.ABSOLUTE_TIMEOUT);
this.transition('active');
this.resetIdleTimer();
// Broadcast to other tabs
authSync.broadcastLogin(user);
}
async logout(reason?: string): Promise<void> {
// Clear timers
if (this.idleTimer) clearTimeout(this.idleTimer);
if (this.absoluteTimer) clearTimeout(this.absoluteTimer);
// Clear session data
localStorage.removeItem('session_start');
localStorage.removeItem('last_activity');
// Revoke tokens server-side
try {
await fetch('/auth/logout', {
method: 'POST',
body: JSON.stringify({ reason }),
});
} catch (error) {
console.error('Logout API call failed:', error);
}
this.transition('ended');
// Broadcast to other tabs
authSync.broadcastLogout();
// Redirect to login
const params = reason ? `?reason=${reason}` : '';
window.location.href = `/login${params}`;
}
private transition(newState: SessionState): void {
const oldState = this.state;
this.state = newState;
console.log(`Session state: ${oldState} → ${newState}`);
this.listeners.forEach(listener => listener(newState));
}
private restoreSession(): void {
const sessionStart = localStorage.getItem('session_start');
if (sessionStart) {
this.checkSessionValidity();
} else {
this.transition('guest');
}
}
subscribe(listener: (state: SessionState) => void): () => void {
this.listeners.add(listener);
listener(this.state); // Immediate call with current state
return () => this.listeners.delete(listener);
}
getState(): SessionState {
return this.state;
}
}
GIF via GIPHY
Step-Up Authentication
Pattern: Require additional authentication for sensitive operations.
// Step-up authentication manager
class StepUpAuthManager {
private stepUpValidUntil: number = 0;
private readonly STEP_UP_DURATION = 15 * 60 * 1000; // 15 minutes
async requireStepUp(
operation: string,
options: StepUpOptions = {}
): Promise<void> {
// Check if already stepped up
if (Date.now() < this.stepUpValidUntil) {
return; // Already authenticated with step-up
}
// Request step-up authentication
const method = options.preferredMethod || await this.selectMethod();
switch (method) {
case 'totp':
await this.verifyTOTP();
break;
case 'webauthn':
await this.verifyWebAuthn();
break;
case 'sms':
await this.verifySMS();
break;
case 'email':
await this.verifyEmail();
break;
default:
throw new AuthError('NO_METHOD', 'No step-up method available');
}
// Mark step-up as valid
this.stepUpValidUntil = Date.now() + this.STEP_UP_DURATION;
// Log security event
await this.logStepUp(operation, method);
}
private async selectMethod(): Promise<StepUpMethod> {
// Get available methods for user
const response = await fetch('/auth/step-up/methods');
const { methods } = await response.json();
// Prefer stronger methods
const priority: StepUpMethod[] = ['webauthn', 'totp', 'sms', 'email'];
for (const method of priority) {
if (methods.includes(method)) {
return method;
}
}
throw new AuthError('NO_METHOD', 'No step-up methods configured');
}
private async verifyWebAuthn(): Promise<void> {
// Get challenge from server
const challengeResponse = await fetch('/auth/step-up/webauthn/challenge', {
method: 'POST',
});
const { challenge, allowCredentials } = await challengeResponse.json();
// Request credential assertion
const credential = await navigator.credentials.get({
publicKey: {
challenge: base64UrlDecode(challenge),
allowCredentials: allowCredentials.map((c: any) => ({
id: base64UrlDecode(c.id),
type: 'public-key',
})),
timeout: 60000,
userVerification: 'required',
},
}) as PublicKeyCredential;
const assertionResponse = credential.response as AuthenticatorAssertionResponse;
// Verify with server
const verifyResponse = await fetch('/auth/step-up/webauthn/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: base64UrlEncode(new Uint8Array(credential.rawId)),
authenticatorData: base64UrlEncode(
new Uint8Array(assertionResponse.authenticatorData)
),
clientDataJSON: base64UrlEncode(
new Uint8Array(assertionResponse.clientDataJSON)
),
signature: base64UrlEncode(
new Uint8Array(assertionResponse.signature)
),
}),
});
if (!verifyResponse.ok) {
throw new AuthError('WEBAUTHN_FAILED', 'WebAuthn verification failed');
}
}
private async verifyTOTP(): Promise<void> {
// Show TOTP input modal
const code = await showTOTPModal();
const response = await fetch('/auth/step-up/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError('TOTP_FAILED', error.message);
}
}
isStepUpValid(): boolean {
return Date.now() < this.stepUpValidUntil;
}
clearStepUp(): void {
this.stepUpValidUntil = 0;
}
}
// Usage with sensitive operations
const stepUpAuth = new StepUpAuthManager();
async function transferFunds(amount: number, toAccount: string): Promise<void> {
// Require step-up for transfers > $1000
if (amount > 1000) {
await stepUpAuth.requireStepUp('transfer_funds', {
preferredMethod: 'webauthn',
});
}
// Proceed with transfer
await fetch('/api/transfers', {
method: 'POST',
body: JSON.stringify({ amount, toAccount }),
});
}
Production Monitoring & Alerting
Authentication Metrics
// Authentication metrics collector
class AuthMetrics {
private metrics: Map<string, number[]> = new Map();
recordLoginAttempt(provider: string, success: boolean, durationMs: number): void {
const key = `login_${provider}_${success ? 'success' : 'failure'}`;
this.recordMetric(key, 1);
if (success) {
this.recordMetric(`login_${provider}_duration`, durationMs);
}
}
recordTokenRefresh(success: boolean, durationMs: number): void {
this.recordMetric(`token_refresh_${success ? 'success' : 'failure'}`, 1);
if (success) {
this.recordMetric('token_refresh_duration', durationMs);
}
}
recordSessionDuration(durationMs: number, endReason: string): void {
this.recordMetric('session_duration', durationMs);
this.recordMetric(`session_end_${endReason}`, 1);
}
recordStepUp(method: string, success: boolean): void {
this.recordMetric(`stepup_${method}_${success ? 'success' : 'failure'}`, 1);
}
private recordMetric(name: string, value: number): void {
if (!this.metrics.has(name)) {
this.metrics.set(name, []);
}
this.metrics.get(name)!.push(value);
// Flush periodically
if (this.metrics.get(name)!.length >= 100) {
this.flush(name);
}
}
private async flush(metricName?: string): Promise<void> {
const toFlush = metricName
? [[metricName, this.metrics.get(metricName)!]]
: Array.from(this.metrics.entries());
for (const [name, values] of toFlush) {
if (values.length === 0) continue;
const aggregated = {
name,
count: values.length,
sum: values.reduce((a, b) => a + b, 0),
min: Math.min(...values),
max: Math.max(...values),
avg: values.reduce((a, b) => a + b, 0) / values.length,
p50: this.percentile(values, 0.5),
p95: this.percentile(values, 0.95),
p99: this.percentile(values, 0.99),
timestamp: Date.now(),
};
await navigator.sendBeacon('/metrics/auth', JSON.stringify(aggregated));
this.metrics.set(name, []);
}
}
private percentile(values: number[], p: number): number {
const sorted = [...values].sort((a, b) => a - b);
const index = Math.ceil(sorted.length * p) - 1;
return sorted[index];
}
}
// Alert thresholds
const authAlerts = {
login_failure_rate: {
threshold: 0.1, // 10% failure rate
window: 300000, // 5 minutes
severity: 'warning',
},
token_refresh_failure_rate: {
threshold: 0.05, // 5% failure rate
window: 60000, // 1 minute
severity: 'critical',
},
login_duration_p95: {
threshold: 5000, // 5 seconds
window: 300000,
severity: 'warning',
},
concurrent_sessions_per_user: {
threshold: 10,
severity: 'warning',
},
};
GIF via GIPHY
Production Incidents & Lessons
Incident 1: Token Refresh Storm
Scenario: Millions of users' tokens expired simultaneously, causing auth service overload.
Timeline:
- T+0h: Auth service deployed with new token expiry (1 hour → 15 minutes)
- T+1h: All existing tokens expire within 15-minute window
- T+1h: 2M simultaneous refresh requests
- T+1.5h: Auth service overwhelmed, 99% failure rate
- T+2h: Cascading failures as retry logic amplifies load
- T+3h: Manual intervention, gradual traffic restoration
Root cause: No token expiry staggering, aggressive retry logic.
Fix:
// Staggered token refresh with jitter
function scheduleTokenRefresh(expiresAt: number): void {
const now = Date.now();
const expiresIn = expiresAt - now;
// Refresh at 75-90% of token lifetime (random jitter)
const refreshPercentage = 0.75 + Math.random() * 0.15;
const refreshIn = expiresIn * refreshPercentage;
setTimeout(() => refreshTokens(), refreshIn);
}
// Exponential backoff with jitter on retry
async function refreshWithBackoff(attempt = 0): Promise<TokenSet> {
try {
return await refreshTokens();
} catch (error) {
if (attempt >= 5) throw error;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s + jitter
const baseDelay = Math.pow(2, attempt) * 1000;
const jitter = Math.random() * 1000;
await sleep(baseDelay + jitter);
return refreshWithBackoff(attempt + 1);
}
}
Incident 2: Silent Auth Loop
Scenario: Safari ITP blocked silent auth cookies, causing infinite redirect loop.
GIF via GIPHY
// Problematic code
async function ensureAuthenticated(): Promise<void> {
if (!isAuthenticated()) {
await silentAuth(); // Uses iframe
if (!isAuthenticated()) {
// Safari ITP blocks third-party cookies
// Silent auth always fails
await silentAuth(); // Infinite loop
}
}
}
Fix:
// Detection and fallback for third-party cookie blocking
async function ensureAuthenticated(): Promise<void> {
if (!isAuthenticated()) {
// Check if silent auth is likely to work
if (await isThirdPartyCookieBlocked()) {
// Skip silent auth, go straight to login
redirectToLogin();
return;
}
const silentAuthAttempted = sessionStorage.getItem('silent_auth_attempted');
if (!silentAuthAttempted) {
sessionStorage.setItem('silent_auth_attempted', 'true');
await silentAuth();
}
if (!isAuthenticated()) {
// Silent auth failed, redirect to login
redirectToLogin();
}
}
}
async function isThirdPartyCookieBlocked(): Promise<boolean> {
// Test with same-site iframe
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = '/auth/cookie-test';
const timeout = setTimeout(() => {
resolve(true); // Assume blocked if no response
}, 3000);
window.addEventListener('message', (event) => {
if (event.data.type === 'cookie_test_result') {
clearTimeout(timeout);
resolve(!event.data.cookiesEnabled);
}
}, { once: true });
document.body.appendChild(iframe);
});
}
Incident 3: Cross-Tab Logout Race Condition
Scenario: Logout in one tab didn't propagate to other tabs quickly enough, allowing continued API access.
Fix: Implement synchronous logout with tab coordination:
// Synchronous logout across all tabs
async function logout(): Promise<void> {
// Immediately invalidate local state
tokenManager.clear();
// Broadcast to other tabs BEFORE server call
authSync.broadcastLogout();
// Server-side session invalidation
await fetch('/auth/logout', { method: 'POST' });
// Redirect
window.location.href = '/login';
}
// Handle logout in other tabs
authSync.subscribe((state) => {
if (!state.isAuthenticated) {
// Immediately stop all pending requests
requestQueue.cancelAll();
// Clear local state
tokenManager.clear();
queryClient.clear();
// Redirect without waiting
window.location.href = '/login?reason=logged_out_other_tab';
}
});
Security Checklist
Authentication Implementation
## OAuth/OIDC
- [ ] PKCE used for authorization code flow
- [ ] State parameter validated on callback
- [ ] Nonce validated in ID token
- [ ] ID token signature verified
- [ ] ID token claims validated (iss, aud, exp, iat)
- [ ] Authorization code exchanged server-side (BFF)
## Token Storage
- [ ] Access tokens NOT stored in localStorage
- [ ] Refresh tokens in HttpOnly cookies only
- [ ] Session cookies use Secure, HttpOnly, SameSite
- [ ] Token lifetime appropriate for risk level
## Session Management
- [ ] Idle timeout implemented
- [ ] Absolute timeout implemented
- [ ] Cross-tab session sync implemented
- [ ] Logout invalidates server-side session
- [ ] Session tokens rotated on privilege change
## Token Refresh
- [ ] Refresh token rotation enabled
- [ ] Refresh token reuse detection
- [ ] Staggered refresh scheduling
- [ ] Exponential backoff on failure
- [ ] Cross-tab refresh coordination
## Step-Up Authentication
- [ ] Implemented for sensitive operations
- [ ] Multiple methods supported (TOTP, WebAuthn)
- [ ] Time-limited step-up validity
- [ ] Audit logging for step-up events
GIF via GIPHY
Conclusion
SPA authentication is an architectural challenge requiring careful coordination of browser security constraints, token lifecycle management, and cross-tab synchronization.
Key architectural decisions:
- BFF pattern for token storage when possible—eliminates XSS token theft risk
- PKCE for all OAuth flows—prevents authorization code interception
- Refresh token rotation with reuse detection—limits token theft impact
- Cross-tab coordination—consistent auth state, single refresh leader
- Step-up authentication—risk-based additional verification
GIF via GIPHY
Production metrics to monitor:
- Login success rate: > 99%
- Token refresh success rate: > 99.9%
- Authentication latency P95: < 500ms
- Session duration distribution
- Step-up challenge success rate
Authentication is the foundation of application security. Get it wrong, and nothing else matters.
What did you think?