Authentication & Security Architecture
Security is the one area where "it works" is never enough. A working auth flow with a token in localStorage is a data breach waiting to happen. Senior engineers understand the threat model and design defenses in layers β because attackers only need to find one gap.
Authentication Flows
OAuth 2.0 + OIDC
OAuth 2.0 handles authorization (what can you access). OpenID Connect (OIDC) adds authentication (who are you) on top.
ββββββββββββ ββββββββββββββββ ββββββββββββββββββββ
β User β β Your App β β Identity Providerβ
β (Browser)β β (SPA/BFF) β β (Google, Okta) β
ββββββ¬ββββββ ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ
β β β
β Click "Login" β β
βββββββββββββββββββΆβ β
β β /authorize + β
β β code_challenge β
β βββββββββββββββββββββββΆβ
β β β
β βββββββββββββ Redirect to IdP βββββββββ
β β β
β User logs in β β
βββββββββββββββββββββββββββββββββββββββββββΆβ
β β β
β βββββ Redirect back with auth code βββββ
β β β
β Code to app β β
βββββββββββββββββββΆβ β
β β Exchange code + β
β β code_verifier for β
β β tokens β
β βββββββββββββββββββββββΆβ
β β β
β ββββ access_token ββββββ
β ββββ id_token ββββββββββ
β ββββ refresh_token βββββ
β β β
ββββ Session βββββββ β
β β βPKCE (Proof Key for Code Exchange)
SPAs can't store a client secret. PKCE replaces it with a cryptographic challenge:
async function generatePKCE() {
const verifier = crypto.randomUUID() + crypto.randomUUID();
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return { verifier, challenge };
}
// Step 1: Start auth with challenge
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = new URL('https://idp.example.com/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
// Step 2: Exchange code with verifier
async function handleCallback(code: string) {
const verifier = sessionStorage.getItem('pkce_verifier');
const response = await fetch('https://idp.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier!,
}),
});
return response.json();
}Token Storage: The Critical Decision
Where you store tokens determines your threat surface. There is no perfect option β only trade-offs.
Threat Analysis Matrix
| Storage | XSS Vulnerable | CSRF Vulnerable | Persists Refresh | Accessible by JS |
|---|---|---|---|---|
| localStorage | YES | No | Yes | Yes |
| sessionStorage | YES | No | No (tab only) | Yes |
| HttpOnly Cookie | No | YES | Yes | No |
| In-Memory (JS var) | Partial | No | No (lost on refresh) | Yes (same context) |
| HttpOnly Cookie + CSRF token | No | No | Yes | No |
The Recommended Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Token Storage Architecture β
β β
β Access Token: In-memory JavaScript variable β
β ββ Short-lived (5-15 minutes) β
β ββ Sent via Authorization header β
β ββ Lost on page refresh (by design) β
β ββ Not accessible via XSS (not in storage) β
β β
β Refresh Token: HttpOnly, Secure, SameSite cookie β
β ββ Long-lived (days to weeks) β
β ββ Sent automatically by browser β
β ββ Not accessible via JavaScript β
β ββ Protected from CSRF via SameSite=Strict β
β β
β On page load: silent refresh to get new access tokenβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββlet accessToken: string | null = null;
async function getAccessToken(): Promise<string> {
if (accessToken && !isExpired(accessToken)) {
return accessToken;
}
return silentRefresh();
}
async function silentRefresh(): Promise<string> {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
accessToken = null;
throw new AuthError('Session expired');
}
const { access_token } = await response.json();
accessToken = access_token;
scheduleRefresh(access_token);
return access_token;
}
function scheduleRefresh(token: string) {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
const refreshAt = expiresIn - 60_000; // refresh 1 minute before expiry
setTimeout(silentRefresh, Math.max(refreshAt, 0));
}JWT Anatomy
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. β Header (algorithm, type)
eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSJ9. β Payload (claims)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c β Signature
ββββββββββββββββββββββββββββββββββββββββββββ
β Header β
β { "alg": "RS256", "typ": "JWT" } β
ββββββββββββββββββββββββββββββββββββββββββββ€
β Payload (Claims) β
β { β
β "sub": "user_123", β
β "name": "Alice", β
β "email": "alice@example.com", β
β "roles": ["admin", "editor"], β
β "iat": 1711900800, β
β "exp": 1711901700, β
β "iss": "https://auth.example.com" β
β } β
ββββββββββββββββββββββββββββββββββββββββββββ€
β Signature β
β RS256( β
β base64(header) + "." + base64(payload),β
β private_key β
β ) β
ββββββββββββββββββββββββββββββββββββββββββββFrontend verification: The frontend should NOT verify JWT signatures β that's the backend's job. The frontend can decode the payload to read claims (user info, roles, expiration) but should never trust the JWT without server validation.
function decodeJwtPayload(token: string): JwtPayload {
const payload = token.split('.')[1];
return JSON.parse(atob(payload));
}
function isExpired(token: string): boolean {
const { exp } = decodeJwtPayload(token);
return Date.now() >= exp * 1000;
}Token Refresh Patterns
Silent Refresh (Recommended for SPAs)
ββββββββββββ ββββββββββββ
β Client β β Server β
β β β β
β Access token expired β β
β βββ POST /auth/refresh ββββΆβ β
β β (HttpOnly cookie) β β
β β β Validate β
β β β refresh β
β β β token β
β ββββ New access token ββββββ β
β β β β
β Store in memory β β
β Schedule next refresh β β
ββββββββββββ ββββββββββββRefresh Token Rotation
Each time a refresh token is used, the server issues a new one and invalidates the old one. If an attacker steals a refresh token and the legitimate user refreshes first, the attacker's token is invalid:
Legitimate user: refresh_token_v1 β gets refresh_token_v2 (v1 invalidated)
Attacker: refresh_token_v1 β REJECTED (v1 already used)
Server detects token reuse β invalidates ALL tokens for userRBAC and ABAC
Role-Based Access Control (RBAC)
type Role = 'viewer' | 'editor' | 'admin' | 'superadmin';
const permissions: Record<Role, string[]> = {
viewer: ['read:posts', 'read:comments'],
editor: ['read:posts', 'read:comments', 'write:posts', 'write:comments'],
admin: ['read:posts', 'read:comments', 'write:posts', 'write:comments', 'manage:users'],
superadmin: ['*'],
};
function hasPermission(userRole: Role, permission: string): boolean {
const userPermissions = permissions[userRole];
return userPermissions.includes('*') || userPermissions.includes(permission);
}Attribute-Based Access Control (ABAC)
More granular β decisions based on user attributes, resource attributes, and context:
interface Policy {
effect: 'allow' | 'deny';
condition: (context: {
user: User;
resource: Resource;
action: string;
environment: { time: Date; ip: string };
}) => boolean;
}
const policies: Policy[] = [
{
effect: 'allow',
condition: ({ user, resource, action }) =>
action === 'edit' && resource.ownerId === user.id,
},
{
effect: 'allow',
condition: ({ user, action }) =>
action === 'edit' && user.roles.includes('admin'),
},
{
effect: 'deny',
condition: ({ environment }) => {
const hour = environment.time.getHours();
return hour < 6 || hour > 22; // deny outside business-adjacent hours
},
},
];Route Protection
Client-Side Guards
function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const { user, isLoading } = useAuth();
if (isLoading) return <PageSkeleton />;
if (!user) return <Navigate to="/login" replace />;
if (requiredRole && !hasRole(user, requiredRole)) return <Navigate to="/unauthorized" />;
return children;
}
// Usage
<Route path="/admin/*" element={
<ProtectedRoute requiredRole="admin">
<AdminDashboard />
</ProtectedRoute>
} />Critical: Client-side guards are UX features, not security features. A determined user can bypass them. The server must enforce authorization on every API call.
Middleware (Next.js / Server-Side)
// middleware.ts (Next.js)
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (token) {
const payload = decodeJwtPayload(token);
if (request.nextUrl.pathname.startsWith('/admin') && !payload.roles.includes('admin')) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
};CSRF Protection in SPAs
Cross-Site Request Forgery tricks the browser into making authenticated requests to your API from a malicious site.
The SameSite Cookie Defense
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/SameSite=Strict prevents the cookie from being sent on cross-origin requests β the primary CSRF vector.
Double-Submit Cookie Pattern
For APIs that need cross-origin support:
// Server sets a CSRF token cookie (readable by JS, NOT HttpOnly)
// Set-Cookie: csrf_token=random123; Secure; SameSite=Lax
// Client reads the cookie and sends it as a header
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken!,
},
credentials: 'include',
body: JSON.stringify({ amount: 100 }),
});The attacker can trigger the request (cookie sent automatically) but cannot read the cookie value to set the header.
XSS Prevention Architecture
XSS is the most common web vulnerability. Defense is layered:
Layer 1: Output Encoding
React escapes by default. The danger is dangerouslySetInnerHTML:
// β
Safe β React escapes automatically
<div>{userInput}</div>
// β Dangerous β raw HTML injection
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// β
If you must render HTML, sanitize first
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />Layer 2: Content Security Policy
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'unsafe-inline';
img-src 'self' https://cdn.example.com;
connect-src 'self' https://api.example.com;
font-src 'self' https://fonts.googleapis.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';What each directive prevents:
| Directive | Prevents |
|---|---|
script-src 'self' | Inline scripts, external script injection |
frame-ancestors 'none' | Clickjacking (no iframe embedding) |
base-uri 'self' | Base tag injection |
connect-src 'self' | Data exfiltration to external domains |
Layer 3: Sanitize User Input
const ALLOWED_TAGS = ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'];
const ALLOWED_ATTRS = { a: ['href', 'title'] };
function sanitizeUserContent(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: Object.values(ALLOWED_ATTRS).flat(),
});
}Subresource Integrity (SRI)
Ensure CDN-hosted scripts haven't been tampered with:
<script
src="https://cdn.example.com/vendor.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous"
></script>If the file's hash doesn't match, the browser refuses to execute it.
Secure Headers Checklist
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Header β Purpose β
βββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββ
β Content-Security-Policy β XSS, injection prevention β
β Strict-Transport-Security β Force HTTPS (HSTS) β
β X-Content-Type-Options β Prevent MIME sniffing β
β X-Frame-Options β Clickjacking prevention β
β Referrer-Policy β Control referrer leakage β
β Permissions-Policy β Restrict browser features β
β Cross-Origin-Opener-Policy β Isolate browsing context β
β Cross-Origin-Resource-Policy β Prevent cross-origin reads β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββImplementation (Next.js)
// next.config.js
const securityHeaders = [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-XSS-Protection', value: '0' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
];
module.exports = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};Security Architecture Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Security Layers β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Browser Layer β β
β β CSP β SRI β CORS β SameSite Cookies β HSTS β β
β ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ β
β β Application Layer β β
β β Input Sanitization β Output Encoding β CSRF Tokens β β
β β Route Guards β Token Management β Error Handling β β
β ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ β
β β Network Layer β β
β β HTTPS β Certificate Pinning β API Gateway β WAF β β
β ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ β
β β Server Layer β β
β β Auth Middleware β Rate Limiting β Input Validation β β
β β RBAC/ABAC β Audit Logging β Token Verification β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThe Interview Framing
When asked about auth and security in a system design interview, structure your answer as:
- Authentication flow β "I'd use OAuth 2.0 + PKCE since this is an SPA. No client secret storage needed."
- Token storage β "Access token in memory, refresh token in HttpOnly Secure SameSite cookie."
- Session lifecycle β "Silent refresh before expiry, refresh token rotation for theft detection."
- Authorization β "RBAC for route protection client-side, enforced server-side on every API call."
- XSS prevention β "CSP headers, DOMPurify for user-generated content, no dangerouslySetInnerHTML."
- CSRF prevention β "SameSite=Strict cookies. If cross-origin needed, double-submit pattern."
Security isn't one feature. It's a posture β a set of layered defenses where each layer assumes the others might fail. That's defense in depth. That's what architects build.