Authentication & Session Architecture: From JWTs to Zero-Trust
Authentication & Session Architecture: From JWTs to Zero-Trust
Authentication Architecture Overview
Authentication is the cornerstone of application security. The architecture you choose affects security posture, user experience, scalability, and operational complexity.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Authentication Methods Comparison │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Session-Based Token-Based (JWT) Token + Refresh │
│ ┌────────────────────────────────────────────────────────────────────────┐│
│ │ Storage │ Server (Redis/DB) │ Client only │ Client + Server ││
│ │ Stateless │ No │ Yes │ Partial ││
│ │ Revocation │ Immediate │ Difficult │ Via refresh ││
│ │ Scalability│ Needs shared store│ Excellent │ Good ││
│ │ Security │ CSRF vulnerable │ XSS vulnerable │ Balanced ││
│ │ Size │ Small cookie │ Large (payload) │ Medium ││
│ │ Mobile │ Cookie issues │ Works well │ Works well ││
│ └────────────────────────────────────────────────────────────────────────┘│
│ │
│ Modern Recommendation: Short-lived JWT + Refresh Token Rotation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
JWT Architecture Deep Dive
JWT Structure and Validation
┌─────────────────────────────────────────────────────────────────────────────┐
│ JWT Token Structure │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. ← Header (Base64)│
│ eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9. │
│ SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature │
│ │
│ Header (decoded): │
│ { │
│ "alg": "RS256", // Algorithm: RSA with SHA-256 │
│ "typ": "JWT", // Token type │
│ "kid": "key-1" // Key ID for key rotation │
│ } │
│ │
│ Payload (decoded): │
│ { │
│ "sub": "user-123", // Subject (user ID) │
│ "iss": "https://auth.app", // Issuer │
│ "aud": "https://api.app", // Audience │
│ "exp": 1699999999, // Expiration (Unix timestamp) │
│ "iat": 1699996399, // Issued at │
│ "jti": "unique-token-id", // JWT ID (for revocation) │
│ "roles": ["user", "admin"], // Custom claims │
│ "permissions": ["read", "write"] │
│ } │
│ │
│ Signature = RS256( │
│ base64UrlEncode(header) + "." + base64UrlEncode(payload), │
│ privateKey │
│ ) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Token Service Implementation
// ============================================================
// JWT Token Service
// ============================================================
import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
import crypto from 'crypto';
interface TokenPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
}
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface RefreshTokenData {
userId: string;
tokenFamily: string;
createdAt: Date;
expiresAt: Date;
revoked: boolean;
}
class TokenService {
private readonly accessTokenSecret: string;
private readonly refreshTokenSecret: string;
private readonly accessTokenExpiry = '15m';
private readonly refreshTokenExpiry = '7d';
constructor(
private readonly tokenStore: TokenStore,
config: { accessSecret: string; refreshSecret: string }
) {
this.accessTokenSecret = config.accessSecret;
this.refreshTokenSecret = config.refreshSecret;
}
async generateTokenPair(user: User): Promise<TokenPair> {
const tokenFamily = crypto.randomUUID();
const accessToken = this.generateAccessToken(user);
const refreshToken = await this.generateRefreshToken(user.id, tokenFamily);
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}
private generateAccessToken(user: User): string {
const payload: TokenPayload = {
sub: user.id,
email: user.email,
roles: user.roles,
permissions: this.derivePermissions(user.roles),
};
const options: SignOptions = {
algorithm: 'RS256',
expiresIn: this.accessTokenExpiry,
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
jwtid: crypto.randomUUID(),
};
return jwt.sign(payload, this.accessTokenSecret, options);
}
private async generateRefreshToken(
userId: string,
tokenFamily: string
): Promise<string> {
const tokenId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// Store refresh token metadata
await this.tokenStore.saveRefreshToken({
id: tokenId,
userId,
tokenFamily,
createdAt: new Date(),
expiresAt,
revoked: false,
});
const payload = {
sub: userId,
family: tokenFamily,
jti: tokenId,
};
return jwt.sign(payload, this.refreshTokenSecret, {
expiresIn: this.refreshTokenExpiry,
});
}
async verifyAccessToken(token: string): Promise<TokenPayload> {
const options: VerifyOptions = {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
};
try {
const payload = jwt.verify(token, this.accessTokenSecret, options);
return payload as TokenPayload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthError('TOKEN_EXPIRED', 'Access token has expired');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AuthError('INVALID_TOKEN', 'Invalid access token');
}
throw error;
}
}
async refreshTokens(refreshToken: string): Promise<TokenPair> {
let payload: JwtPayload;
try {
payload = jwt.verify(refreshToken, this.refreshTokenSecret) as JwtPayload;
} catch {
throw new AuthError('INVALID_REFRESH_TOKEN', 'Invalid refresh token');
}
const storedToken = await this.tokenStore.getRefreshToken(payload.jti!);
if (!storedToken) {
throw new AuthError('TOKEN_NOT_FOUND', 'Refresh token not found');
}
if (storedToken.revoked) {
// Potential token theft - revoke entire family
await this.tokenStore.revokeTokenFamily(storedToken.tokenFamily);
throw new AuthError('TOKEN_REUSE', 'Refresh token has been revoked');
}
if (storedToken.expiresAt < new Date()) {
throw new AuthError('TOKEN_EXPIRED', 'Refresh token has expired');
}
// Revoke old refresh token (rotation)
await this.tokenStore.revokeRefreshToken(payload.jti!);
// Generate new token pair with same family
const user = await this.userService.findById(storedToken.userId);
const accessToken = this.generateAccessToken(user);
const newRefreshToken = await this.generateRefreshToken(
user.id,
storedToken.tokenFamily
);
return {
accessToken,
refreshToken: newRefreshToken,
expiresIn: 900,
};
}
async revokeAllUserTokens(userId: string): Promise<void> {
await this.tokenStore.revokeAllUserTokens(userId);
}
private derivePermissions(roles: string[]): string[] {
const permissionMap: Record<string, string[]> = {
admin: ['read', 'write', 'delete', 'manage_users'],
editor: ['read', 'write'],
viewer: ['read'],
};
const permissions = new Set<string>();
roles.forEach((role) => {
permissionMap[role]?.forEach((p) => permissions.add(p));
});
return Array.from(permissions);
}
}
class AuthError extends Error {
constructor(
public code: string,
message: string
) {
super(message);
this.name = 'AuthError';
}
}
Frontend Authentication Implementation
Auth Context and Provider
// ============================================================
// React Auth Context
// ============================================================
interface User {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
}
interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
hasPermission: (permission: string) => boolean;
hasRole: (role: string) => boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
// Token storage with security considerations
class TokenStorage {
private accessToken: string | null = null;
// Access token in memory only (XSS mitigation)
setAccessToken(token: string): void {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
clearAccessToken(): void {
this.accessToken = null;
}
// Refresh token in httpOnly cookie (set by server)
// No client-side access needed
}
const tokenStorage = new TokenStorage();
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
isAuthenticated: false,
isLoading: true,
});
// Initialize auth state on mount
useEffect(() => {
initializeAuth();
}, []);
// Set up token refresh interval
useEffect(() => {
if (!state.isAuthenticated) return;
// Refresh token 1 minute before expiry
const refreshInterval = setInterval(() => {
refreshSession();
}, 14 * 60 * 1000); // 14 minutes
return () => clearInterval(refreshInterval);
}, [state.isAuthenticated]);
// Handle tab visibility for session sync
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// Re-validate session when tab becomes visible
validateSession();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
async function initializeAuth(): Promise<void> {
try {
// Try to refresh session using httpOnly refresh token cookie
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Include cookies
});
if (response.ok) {
const data = await response.json();
tokenStorage.setAccessToken(data.accessToken);
setState({
user: data.user,
isAuthenticated: true,
isLoading: false,
});
} else {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
} catch {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
}
async function login(email: string, password: string): Promise<void> {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
const data = await response.json();
tokenStorage.setAccessToken(data.accessToken);
setState({
user: data.user,
isAuthenticated: true,
isLoading: false,
});
}
async function logout(): Promise<void> {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
} finally {
tokenStorage.clearAccessToken();
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}
}
async function refreshSession(): Promise<void> {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
tokenStorage.setAccessToken(data.accessToken);
} else {
// Refresh failed - log out user
await logout();
}
} catch {
await logout();
}
}
async function validateSession(): Promise<void> {
const token = tokenStorage.getAccessToken();
if (!token) {
await refreshSession();
return;
}
// Check if token is about to expire
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresAt = payload.exp * 1000;
const now = Date.now();
if (expiresAt - now < 60000) {
// Less than 1 minute to expiry
await refreshSession();
}
}
function hasPermission(permission: string): boolean {
return state.user?.permissions.includes(permission) ?? false;
}
function hasRole(role: string): boolean {
return state.user?.roles.includes(role) ?? false;
}
return (
<AuthContext.Provider
value={{
...state,
login,
logout,
refreshSession,
hasPermission,
hasRole,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// ============================================================
// Authenticated Fetch Wrapper
// ============================================================
export async function authFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const token = tokenStorage.getAccessToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: token ? `Bearer ${token}` : '',
},
credentials: 'include',
});
// Handle token expiration
if (response.status === 401) {
// Try to refresh token
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (refreshResponse.ok) {
const data = await refreshResponse.json();
tokenStorage.setAccessToken(data.accessToken);
// Retry original request
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${data.accessToken}`,
},
credentials: 'include',
});
}
// Refresh failed - redirect to login
window.location.href = '/login';
}
return response;
}
Protected Routes
// ============================================================
// Route Protection Components
// ============================================================
interface ProtectedRouteProps {
children: React.ReactNode;
requiredPermissions?: string[];
requiredRoles?: string[];
fallback?: React.ReactNode;
}
export function ProtectedRoute({
children,
requiredPermissions = [],
requiredRoles = [],
fallback = <Navigate to="/login" />,
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading, hasPermission, hasRole } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
if (!isAuthenticated) {
return fallback;
}
// Check permissions
const hasRequiredPermissions = requiredPermissions.every(hasPermission);
const hasRequiredRoles =
requiredRoles.length === 0 || requiredRoles.some(hasRole);
if (!hasRequiredPermissions || !hasRequiredRoles) {
return <ForbiddenPage />;
}
return <>{children}</>;
}
// Usage
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requiredRoles={['admin']}>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requiredPermissions={['manage_settings']}>
<SettingsPage />
</ProtectedRoute>
}
/>
</Routes>
</Router>
</AuthProvider>
);
}
// Permission-based UI rendering
export function PermissionGate({
permission,
children,
fallback = null,
}: {
permission: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { hasPermission } = useAuth();
if (!hasPermission(permission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Usage
function ArticleActions({ article }: { article: Article }) {
return (
<div>
<button>View</button>
<PermissionGate permission="write">
<button>Edit</button>
</PermissionGate>
<PermissionGate permission="delete">
<button>Delete</button>
</PermissionGate>
</div>
);
}
OAuth 2.0 / OpenID Connect Integration
┌─────────────────────────────────────────────────────────────────────────────┐
│ OAuth 2.0 Authorization Code Flow with PKCE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Frontend Backend Auth Server Resource Server │
│ │ │ │ │ │ │
│ │ 1. Click │ │ │ │ │
│ │ "Login" │ │ │ │ │
│ │────────────►│ │ │ │ │
│ │ │ │ │ │ │
│ │ │ 2. Generate code_verifier, │ │ │
│ │ │ code_challenge = SHA256(verifier) │ │
│ │ │ │ │ │ │
│ │ │ 3. Redirect to Auth Server │ │ │
│ │◄────────────│───────────────────────────────►│ │ │
│ │ │ /authorize? │ │ │
│ │ │ response_type=code& │ │ │
│ │ │ client_id=xxx& │ │ │
│ │ │ redirect_uri=xxx& │ │ │
│ │ │ code_challenge=xxx& │ │ │
│ │ │ code_challenge_method=S256&│ │ │
│ │ │ scope=openid profile │ │ │
│ │ │ │ │ │ │
│ │ 4. Login at Auth Server │ │ │ │
│ │────────────────────────────────────────────►│ │ │
│ │ │ │ │ │ │
│ │ 5. Redirect back with code │ │ │ │
│ │◄───────────────────────────────────────────│ │ │
│ │ /callback?code=xxx │ │ │ │
│ │────────────►│ │ │ │ │
│ │ │ │ │ │ │
│ │ │ 6. Send code + code_verifier │ │ │
│ │ │───────────────► │ │ │
│ │ │ │ 7. POST /token │ │ │
│ │ │ │────────────────►│ │ │
│ │ │ │ code=xxx& │ │ │
│ │ │ │ code_verifier=xxx │ │
│ │ │ │ │ │ │
│ │ │ │ 8. Verify PKCE, │ │ │
│ │ │ │ return tokens│ │ │
│ │ │ │◄────────────────│ │ │
│ │ │ │ {access_token,│ │ │
│ │ │ │ id_token, │ │ │
│ │ │ │ refresh_token} │ │
│ │ │ 9. Set session│ │ │ │
│ │ │◄──────────────│ │ │ │
│ │ │ │ │ │ │
│ │ 10. Authenticated │ │ │ │
│ │◄────────────│ │ │ │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
OAuth Client Implementation
// ============================================================
// OAuth 2.0 + PKCE Client
// ============================================================
interface OAuthConfig {
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
redirectUri: string;
scopes: string[];
}
class OAuthClient {
private codeVerifier: string | null = null;
constructor(private config: OAuthConfig) {}
async initiateLogin(): Promise<void> {
// Generate PKCE code verifier and challenge
this.codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(this.codeVerifier);
// Store verifier for token exchange
sessionStorage.setItem('oauth_code_verifier', this.codeVerifier);
// Generate state for CSRF protection
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// Redirect to authorization server
window.location.href = `${this.config.authorizationEndpoint}?${params}`;
}
async handleCallback(code: string, state: string): Promise<TokenResponse> {
// Verify state
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state parameter');
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
if (!codeVerifier) {
throw new Error('Missing code verifier');
}
// Exchange code for tokens
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.clientId,
code,
redirect_uri: this.config.redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
// Clean up
sessionStorage.removeItem('oauth_code_verifier');
sessionStorage.removeItem('oauth_state');
return response.json();
}
private generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return this.base64UrlEncode(array);
}
private async generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return this.base64UrlEncode(new Uint8Array(hash));
}
private base64UrlEncode(array: Uint8Array): string {
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
}
// Social Login Buttons
function SocialLoginButtons() {
const googleClient = new OAuthClient({
clientId: process.env.GOOGLE_CLIENT_ID!,
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
redirectUri: `${window.location.origin}/auth/callback/google`,
scopes: ['openid', 'email', 'profile'],
});
const githubClient = new OAuthClient({
clientId: process.env.GITHUB_CLIENT_ID!,
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
redirectUri: `${window.location.origin}/auth/callback/github`,
scopes: ['read:user', 'user:email'],
});
return (
<div className="social-login">
<button onClick={() => googleClient.initiateLogin()}>
Continue with Google
</button>
<button onClick={() => githubClient.initiateLogin()}>
Continue with GitHub
</button>
</div>
);
}
Session Security Best Practices
// ============================================================
// Security Headers and Cookie Configuration
// ============================================================
// Server-side cookie settings
const COOKIE_OPTIONS = {
httpOnly: true, // Prevent JavaScript access
secure: true, // HTTPS only
sameSite: 'strict' as const, // CSRF protection
path: '/',
maxAge: 7 * 24 * 60 * 60, // 7 days
domain: '.example.com', // Allow subdomains
};
// Express middleware for security headers
function securityHeaders(req: Request, res: Response, next: NextFunction) {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// XSS protection
res.setHeader('X-XSS-Protection', '1; mode=block');
// Content Security Policy
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.example.com; " +
"frame-ancestors 'none';"
);
// HSTS
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
next();
}
// ============================================================
// CSRF Protection
// ============================================================
import csrf from 'csurf';
import cookieParser from 'cookie-parser';
// CSRF middleware
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
},
});
// Provide CSRF token to frontend
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Frontend: Include CSRF token in requests
async function fetchWithCSRF(url: string, options: RequestInit = {}) {
// Get CSRF token
const tokenResponse = await fetch('/api/csrf-token', {
credentials: 'include',
});
const { csrfToken } = await tokenResponse.json();
return fetch(url, {
...options,
headers: {
...options.headers,
'X-CSRF-Token': csrfToken,
},
credentials: 'include',
});
}
// ============================================================
// Rate Limiting for Auth Endpoints
// ============================================================
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
const authLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rate_limit:auth:',
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: {
error: 'Too many login attempts. Please try again later.',
},
keyGenerator: (req) => {
// Rate limit by IP + email combination
return `${req.ip}:${req.body.email}`;
},
skip: (req) => {
// Skip rate limiting for successful requests
return false;
},
});
app.post('/api/auth/login', authLimiter, loginHandler);
Multi-Factor Authentication (MFA)
// ============================================================
// TOTP-based MFA
// ============================================================
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
class MFAService {
async setupMFA(user: User): Promise<{ secret: string; qrCode: string }> {
const secret = speakeasy.generateSecret({
name: `MyApp:${user.email}`,
issuer: 'MyApp',
});
// Store secret (encrypted) in database
await this.userService.updateMFASecret(user.id, secret.base32);
// Generate QR code for authenticator apps
const qrCode = await QRCode.toDataURL(secret.otpauth_url!);
return {
secret: secret.base32,
qrCode,
};
}
async verifyMFA(user: User, token: string): Promise<boolean> {
const secret = await this.userService.getMFASecret(user.id);
if (!secret) {
throw new Error('MFA not configured');
}
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 1, // Allow 1 step tolerance
});
}
async generateBackupCodes(user: User): Promise<string[]> {
const codes: string[] = [];
for (let i = 0; i < 10; i++) {
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
codes.push(code);
}
// Hash and store backup codes
const hashedCodes = await Promise.all(
codes.map((code) => bcrypt.hash(code, 10))
);
await this.userService.setBackupCodes(user.id, hashedCodes);
return codes; // Return unhashed codes to user (only time they'll see them)
}
async verifyBackupCode(user: User, code: string): Promise<boolean> {
const hashedCodes = await this.userService.getBackupCodes(user.id);
for (let i = 0; i < hashedCodes.length; i++) {
if (await bcrypt.compare(code, hashedCodes[i])) {
// Remove used backup code
hashedCodes.splice(i, 1);
await this.userService.setBackupCodes(user.id, hashedCodes);
return true;
}
}
return false;
}
}
// Login flow with MFA
async function loginWithMFA(req: Request, res: Response) {
const { email, password, mfaToken } = req.body;
// Step 1: Verify credentials
const user = await authService.verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Step 2: Check if MFA is enabled
if (user.mfaEnabled) {
if (!mfaToken) {
// Return partial authentication - needs MFA
const partialToken = jwt.sign(
{ sub: user.id, mfaPending: true },
process.env.JWT_SECRET!,
{ expiresIn: '5m' }
);
return res.json({
requiresMFA: true,
partialToken,
});
}
// Verify MFA token
const mfaValid = await mfaService.verifyMFA(user, mfaToken);
if (!mfaValid) {
return res.status(401).json({ error: 'Invalid MFA token' });
}
}
// Step 3: Issue full tokens
const tokens = await tokenService.generateTokenPair(user);
res.cookie('refreshToken', tokens.refreshToken, COOKIE_OPTIONS);
res.json({
accessToken: tokens.accessToken,
user: sanitizeUser(user),
});
}
Key Takeaways
-
Never store access tokens in localStorage: Use memory for access tokens, httpOnly cookies for refresh tokens
-
Implement token rotation: Refresh tokens should be single-use to detect theft
-
Short-lived access tokens: 15 minutes max; refresh silently in the background
-
Always use PKCE for OAuth: Even for confidential clients; it's the modern standard
-
Defense in depth: Combine multiple security measures (CSRF, rate limiting, MFA)
-
Secure cookie settings: httpOnly, secure, sameSite=strict
-
Handle token expiration gracefully: Silent refresh, queue failed requests, retry after refresh
-
Implement proper logout: Revoke tokens server-side, clear all client storage
-
Monitor auth events: Log login attempts, failures, token refreshes for security auditing
-
Plan for session sync: Handle multiple tabs, visibility changes, network reconnection
What did you think?