Security Boundary Design in Modern Frontend Architectures
Security Boundary Design in Modern Frontend Architectures
Trust Zones in a Multi-Layer Architecture
Modern frontend architectures span multiple execution environments—browsers, edge workers, SSR servers, and API gateways—each with different trust levels and security properties. Understanding where security boundaries exist, how tokens should propagate, and which attack surfaces each layer exposes is essential for building secure applications.
This article examines security boundary design across the full frontend stack, from browser to origin.
Trust Boundary Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ Security Trust Boundaries │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ UNTRUSTED ZONE │
│ ────────────── │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Browser │ │
│ │ │ │
│ │ • User-controlled environment │ │
│ │ • Code can be inspected/modified │ │
│ │ • Storage is accessible to user │ │
│ │ • Network requests visible in devtools │ │
│ │ • Third-party scripts run in same context │ │
│ │ │ │
│ │ Trust Level: ZERO │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ HTTPS │
│ ═══════════════════════════════════╪═══════════════════════════════════ │
│ │ │
│ SEMI-TRUSTED ZONE ▼ │
│ ───────────────── │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Edge / CDN Workers │ │
│ │ │ │
│ │ • Provider-controlled environment │ │
│ │ • Limited secrets access │ │
│ │ • Short execution time │ │
│ │ • Can validate tokens, not issue them │ │
│ │ • Stateless (no persistent storage) │ │
│ │ │ │
│ │ Trust Level: PARTIAL │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ═══════════════════════════════════╪═══════════════════════════════════ │
│ │ │
│ TRUSTED ZONE ▼ │
│ ──────────── │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ SSR / API Servers │ │
│ │ │ │
│ │ • Organization-controlled infrastructure │ │
│ │ • Full secrets access │ │
│ │ • Can issue tokens │ │
│ │ • Database access │ │
│ │ • Full audit logging │ │
│ │ │ │
│ │ Trust Level: HIGH │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ═══════════════════════════════════╪═══════════════════════════════════ │
│ │ │
│ FULLY TRUSTED ZONE ▼ │
│ ────────────────── │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Database │ │
│ │ │ │
│ │ • Internal network only │ │
│ │ • No external access │ │
│ │ • Row-level security │ │
│ │ • Encryption at rest │ │
│ │ │ │
│ │ Trust Level: MAXIMUM │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Token Propagation Patterns
Token Flow Across Boundaries
// src/security/token-propagation.ts
interface TokenConfig {
accessTokenTTL: number; // Short-lived (15min)
refreshTokenTTL: number; // Longer-lived (7 days)
sessionTokenTTL: number; // Server-side session (24h)
csrfTokenTTL: number; // Per-request or session
}
interface SecurityContext {
userId: string;
roles: string[];
permissions: string[];
sessionId: string;
deviceId: string;
ipAddress: string;
userAgent: string;
}
// Token types for different boundaries
interface AccessToken {
type: 'access';
sub: string; // User ID
exp: number; // Expiration
iat: number; // Issued at
scope: string[]; // Permissions
aud: string; // Audience (which API)
jti: string; // Token ID for revocation
}
interface RefreshToken {
type: 'refresh';
sub: string;
exp: number;
iat: number;
family: string; // Token family for rotation
jti: string;
}
interface SessionToken {
type: 'session';
sid: string; // Session ID
exp: number;
csrf: string; // CSRF token bound to session
}
// Browser → Edge: Token validation
class EdgeTokenValidator {
private publicKey: CryptoKey;
constructor(private jwksUrl: string) {}
async initialize(): Promise<void> {
const response = await fetch(this.jwksUrl);
const jwks = await response.json();
// Import public key for verification
this.publicKey = await crypto.subtle.importKey(
'jwk',
jwks.keys[0],
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
true,
['verify']
);
}
async validate(token: string): Promise<AccessToken | null> {
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Verify signature
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = this.base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
this.publicKey,
signature,
data
);
if (!valid) return null;
// Decode payload
const payload = JSON.parse(
new TextDecoder().decode(this.base64UrlDecode(payloadB64))
) as AccessToken;
// Check expiration
if (payload.exp < Date.now() / 1000) {
return null;
}
return payload;
} catch (error) {
return null;
}
}
private base64UrlDecode(str: string): ArrayBuffer {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
}
// Edge → Origin: Token forwarding with additional context
interface ForwardedAuthContext {
accessToken: string;
tokenValidatedAt: number;
clientIp: string;
clientCountry: string;
edgeLocation: string;
requestId: string;
}
function createForwardHeaders(
request: Request,
token: AccessToken
): Headers {
const headers = new Headers(request.headers);
// Forward validated token info (not the token itself for origin to re-validate)
headers.set('X-Validated-User-Id', token.sub);
headers.set('X-Validated-Scopes', token.scope.join(','));
headers.set('X-Token-Exp', token.exp.toString());
// Add edge context
headers.set('X-Client-IP', request.headers.get('CF-Connecting-IP') || '');
headers.set('X-Client-Country', (request as any).cf?.country || '');
headers.set('X-Edge-Location', (request as any).cf?.colo || '');
headers.set('X-Request-ID', crypto.randomUUID());
// Sign the headers to prevent tampering
// (In production, use HMAC with shared secret between edge and origin)
return headers;
}
// Server-side: Token refresh handling
class TokenRefreshHandler {
constructor(
private config: TokenConfig,
private tokenStore: TokenStore,
private auditLog: AuditLogger
) {}
async refresh(refreshToken: string): Promise<{
accessToken: string;
refreshToken: string;
} | null> {
// Decode refresh token
const payload = this.decodeToken(refreshToken) as RefreshToken;
if (!payload) return null;
// Check if token is revoked
const isRevoked = await this.tokenStore.isRevoked(payload.jti);
if (isRevoked) {
// Possible token theft - revoke entire family
await this.tokenStore.revokeFamily(payload.family);
await this.auditLog.log({
type: 'TOKEN_THEFT_DETECTED',
userId: payload.sub,
tokenFamily: payload.family,
attemptedToken: payload.jti,
});
return null;
}
// Revoke current refresh token (rotation)
await this.tokenStore.revoke(payload.jti);
// Issue new tokens
const newAccessToken = await this.issueAccessToken(payload.sub);
const newRefreshToken = await this.issueRefreshToken(payload.sub, payload.family);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
private decodeToken(token: string): RefreshToken | null {
try {
const [, payloadB64] = token.split('.');
return JSON.parse(atob(payloadB64));
} catch {
return null;
}
}
private async issueAccessToken(userId: string): Promise<string> {
// Implementation
return '';
}
private async issueRefreshToken(userId: string, family: string): Promise<string> {
// Implementation
return '';
}
}
interface TokenStore {
isRevoked(tokenId: string): Promise<boolean>;
revoke(tokenId: string): Promise<void>;
revokeFamily(family: string): Promise<void>;
}
interface AuditLogger {
log(event: Record<string, unknown>): Promise<void>;
}
export {
TokenConfig,
SecurityContext,
AccessToken,
RefreshToken,
SessionToken,
EdgeTokenValidator,
TokenRefreshHandler,
createForwardHeaders,
};
SSR Attack Surface
Server-Side Rendering Security Concerns
┌─────────────────────────────────────────────────────────────────────────────┐
│ SSR Attack Surface │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. DATA EXPOSURE │
│ ──────────────── │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ Server │───▶│ HTML │───▶│ Browser │ │
│ │ Fetches │ │ Output │ │ │ │
│ │ user.email│ │ Contains │ │ View Source reveals: │ │
│ │ user.ssn │ │ ALL data │ │ - user.email ✓ │ │
│ │ user.role │ │ in script │ │ - user.ssn ✗ LEAK! │ │
│ └─────────────┘ └─────────────┘ │ - user.role ✗ LEAK! │ │
│ └─────────────────────────────┘ │
│ │
│ MITIGATION: Filter data before serialization │
│ ───────────────────────────────────────────── │
│ │
│ 2. XSS VIA SSR │
│ ───────────── │
│ │
│ User input: <script>alert('XSS')</script> │
│ │ │
│ ▼ │
│ SSR renders: <div>{userInput}</div> │
│ │ │
│ ▼ │
│ HTML output: <div><script>alert('XSS')</script></div> │
│ │
│ MITIGATION: Always escape, use dangerouslySetInnerHTML with caution │
│ ───────────────────────────────────────────────────────────────── │
│ │
│ 3. SSRF (Server-Side Request Forgery) │
│ ───────────────────────────────────── │
│ │
│ URL param: ?imageUrl=http://internal-api/admin/secrets │
│ │ │
│ ▼ │
│ SSR fetches: fetch(params.imageUrl) ← Fetches internal resource! │
│ │
│ MITIGATION: URL allowlisting, network isolation │
│ ─────────────────────────────────────────────── │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Secure SSR Implementation
// src/security/secure-ssr.ts
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
// Data filtering for SSR serialization
interface SanitizationRules {
// Fields to always exclude
excludeFields: string[];
// Fields to include only for specific roles
roleBasedFields: Record<string, string[]>;
// Transform functions for specific fields
transforms: Record<string, (value: unknown) => unknown>;
}
const defaultRules: SanitizationRules = {
excludeFields: [
'password',
'passwordHash',
'ssn',
'socialSecurityNumber',
'creditCard',
'bankAccount',
'apiKey',
'secret',
'token',
'refreshToken',
],
roleBasedFields: {
admin: ['internalNotes', 'adminFlags'],
support: ['supportNotes', 'ticketHistory'],
},
transforms: {
email: (email: unknown) => {
if (typeof email !== 'string') return email;
const [local, domain] = email.split('@');
return `${local.slice(0, 2)}***@${domain}`;
},
phone: (phone: unknown) => {
if (typeof phone !== 'string') return phone;
return phone.replace(/\d(?=\d{4})/g, '*');
},
},
};
function sanitizeForClient<T extends Record<string, unknown>>(
data: T,
userRoles: string[] = [],
rules: SanitizationRules = defaultRules
): Partial<T> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
// Check exclusion list
if (rules.excludeFields.some(f =>
key.toLowerCase().includes(f.toLowerCase())
)) {
continue;
}
// Check role-based fields
let isRoleRestricted = false;
for (const [role, fields] of Object.entries(rules.roleBasedFields)) {
if (fields.includes(key) && !userRoles.includes(role)) {
isRoleRestricted = true;
break;
}
}
if (isRoleRestricted) continue;
// Apply transforms
if (rules.transforms[key]) {
result[key] = rules.transforms[key](value);
} else if (typeof value === 'object' && value !== null) {
// Recursively sanitize nested objects
result[key] = sanitizeForClient(
value as Record<string, unknown>,
userRoles,
rules
);
} else {
result[key] = value;
}
}
return result as Partial<T>;
}
// HTML sanitization for user content
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
interface SanitizeHtmlOptions {
allowedTags?: string[];
allowedAttributes?: Record<string, string[]>;
allowedSchemes?: string[];
}
function sanitizeHtml(
html: string,
options: SanitizeHtmlOptions = {}
): string {
const config: DOMPurify.Config = {
ALLOWED_TAGS: options.allowedTags || [
'b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
],
ALLOWED_ATTR: ['href', 'title', 'class'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
ALLOW_DATA_ATTR: false,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
};
return DOMPurify.sanitize(html, config);
}
// SSRF protection
class URLValidator {
private allowedHosts: Set<string>;
private blockedIpRanges: Array<{ start: number; end: number }>;
constructor(config: {
allowedHosts: string[];
blockPrivateIps?: boolean;
}) {
this.allowedHosts = new Set(config.allowedHosts);
this.blockedIpRanges = config.blockPrivateIps !== false
? this.getPrivateIpRanges()
: [];
}
private getPrivateIpRanges(): Array<{ start: number; end: number }> {
return [
// 10.0.0.0/8
{ start: this.ipToNum('10.0.0.0'), end: this.ipToNum('10.255.255.255') },
// 172.16.0.0/12
{ start: this.ipToNum('172.16.0.0'), end: this.ipToNum('172.31.255.255') },
// 192.168.0.0/16
{ start: this.ipToNum('192.168.0.0'), end: this.ipToNum('192.168.255.255') },
// 127.0.0.0/8 (localhost)
{ start: this.ipToNum('127.0.0.0'), end: this.ipToNum('127.255.255.255') },
// 169.254.0.0/16 (link-local)
{ start: this.ipToNum('169.254.0.0'), end: this.ipToNum('169.254.255.255') },
];
}
private ipToNum(ip: string): number {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
}
async validate(urlString: string): Promise<{
valid: boolean;
reason?: string;
}> {
try {
const url = new URL(urlString);
// Only allow https
if (url.protocol !== 'https:') {
return { valid: false, reason: 'Only HTTPS URLs are allowed' };
}
// Check against allowlist
if (this.allowedHosts.size > 0 && !this.allowedHosts.has(url.hostname)) {
return { valid: false, reason: 'Host not in allowlist' };
}
// Resolve hostname and check for private IPs
const addresses = await this.resolveHostname(url.hostname);
for (const addr of addresses) {
const ipNum = this.ipToNum(addr);
for (const range of this.blockedIpRanges) {
if (ipNum >= range.start && ipNum <= range.end) {
return { valid: false, reason: 'Resolved to private IP' };
}
}
}
return { valid: true };
} catch (error) {
return { valid: false, reason: 'Invalid URL' };
}
}
private async resolveHostname(hostname: string): Promise<string[]> {
// In Node.js, use dns.resolve4
// This is a placeholder
const dns = await import('dns').then(m => m.promises);
return dns.resolve4(hostname);
}
}
// Secure fetch wrapper for SSR
async function secureFetch(
url: string,
options: RequestInit = {},
validator: URLValidator
): Promise<Response> {
const validation = await validator.validate(url);
if (!validation.valid) {
throw new Error(`URL validation failed: ${validation.reason}`);
}
// Add security headers
const secureOptions: RequestInit = {
...options,
headers: {
...options.headers,
'User-Agent': 'SSR-Fetcher/1.0',
},
// Disable redirects or validate redirect URLs
redirect: 'manual',
};
const response = await fetch(url, secureOptions);
// Handle redirects securely
if (response.status >= 300 && response.status < 400) {
const redirectUrl = response.headers.get('location');
if (redirectUrl) {
const redirectValidation = await validator.validate(redirectUrl);
if (!redirectValidation.valid) {
throw new Error(`Redirect URL validation failed: ${redirectValidation.reason}`);
}
return secureFetch(redirectUrl, options, validator);
}
}
return response;
}
export {
sanitizeForClient,
sanitizeHtml,
URLValidator,
secureFetch,
SanitizationRules,
};
Content Security Policy Strategy
Layered CSP Architecture
// src/security/csp.ts
interface CSPDirectives {
'default-src': string[];
'script-src': string[];
'style-src': string[];
'img-src': string[];
'font-src': string[];
'connect-src': string[];
'frame-src': string[];
'object-src': string[];
'base-uri': string[];
'form-action': string[];
'frame-ancestors': string[];
'report-uri'?: string[];
'report-to'?: string[];
}
interface CSPConfig {
reportOnly: boolean;
nonce: boolean;
reportEndpoint: string;
}
class CSPBuilder {
private directives: Partial<CSPDirectives> = {};
private nonce: string | null = null;
constructor(private config: CSPConfig) {
if (config.nonce) {
this.nonce = this.generateNonce();
}
this.setDefaults();
}
private generateNonce(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array));
}
private setDefaults(): void {
this.directives = {
'default-src': ["'self'"],
'script-src': this.nonce
? ["'self'", `'nonce-${this.nonce}'`, "'strict-dynamic'"]
: ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"], // Often needed for CSS-in-JS
'img-src': ["'self'", 'data:', 'https:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'frame-src': ["'none'"],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'frame-ancestors': ["'none'"],
};
if (this.config.reportEndpoint) {
this.directives['report-uri'] = [this.config.reportEndpoint];
}
}
addScriptSrc(...sources: string[]): this {
this.directives['script-src']?.push(...sources);
return this;
}
addStyleSrc(...sources: string[]): this {
this.directives['style-src']?.push(...sources);
return this;
}
addConnectSrc(...sources: string[]): this {
this.directives['connect-src']?.push(...sources);
return this;
}
addImgSrc(...sources: string[]): this {
this.directives['img-src']?.push(...sources);
return this;
}
addFrameSrc(...sources: string[]): this {
this.directives['frame-src'] = this.directives['frame-src']?.filter(s => s !== "'none'") || [];
this.directives['frame-src'].push(...sources);
return this;
}
allowInlineStyles(): this {
if (!this.directives['style-src']?.includes("'unsafe-inline'")) {
this.directives['style-src']?.push("'unsafe-inline'");
}
return this;
}
build(): { header: string; headerName: string; nonce: string | null } {
const directives = Object.entries(this.directives)
.filter(([, values]) => values && values.length > 0)
.map(([key, values]) => `${key} ${values!.join(' ')}`)
.join('; ');
return {
header: directives,
headerName: this.config.reportOnly
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy',
nonce: this.nonce,
};
}
}
// Environment-specific CSP configurations
function createCSPForEnvironment(env: 'development' | 'staging' | 'production'): CSPBuilder {
const config: CSPConfig = {
reportOnly: env === 'development',
nonce: env === 'production',
reportEndpoint: '/api/csp-report',
};
const builder = new CSPBuilder(config);
switch (env) {
case 'development':
builder
.addConnectSrc('ws://localhost:*') // HMR
.addScriptSrc("'unsafe-eval'"); // Source maps
break;
case 'staging':
builder
.addConnectSrc('https://staging-api.example.com')
.addImgSrc('https://staging-cdn.example.com');
break;
case 'production':
builder
.addConnectSrc('https://api.example.com')
.addImgSrc('https://cdn.example.com')
.addScriptSrc('https://cdn.example.com');
break;
}
return builder;
}
// CSP violation reporting endpoint
interface CSPViolationReport {
'document-uri': string;
referrer: string;
'violated-directive': string;
'effective-directive': string;
'original-policy': string;
'blocked-uri': string;
'status-code': number;
}
async function handleCSPReport(report: CSPViolationReport): Promise<void> {
// Filter out known false positives
const ignoredPatterns = [
/^data:/, // Data URIs often trigger false positives
/^chrome-extension:/, // Browser extensions
/^moz-extension:/,
];
if (ignoredPatterns.some(p => p.test(report['blocked-uri']))) {
return;
}
// Log violation
console.warn('CSP Violation:', {
directive: report['violated-directive'],
blockedUri: report['blocked-uri'],
documentUri: report['document-uri'],
});
// Send to monitoring
await fetch('/api/telemetry/csp-violation', {
method: 'POST',
body: JSON.stringify(report),
});
}
export { CSPBuilder, createCSPForEnvironment, handleCSPReport };
Next.js CSP Integration
// next.config.js / middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { CSPBuilder } from './src/security/csp';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Generate nonce
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// Build CSP
const csp = new CSPBuilder({
reportOnly: process.env.NODE_ENV === 'development',
nonce: true,
reportEndpoint: '/api/csp-report',
});
csp
.addConnectSrc(process.env.NEXT_PUBLIC_API_URL!)
.addImgSrc(process.env.NEXT_PUBLIC_CDN_URL!);
const { header, headerName } = csp.build();
// Set headers
response.headers.set(headerName, header.replace(/'nonce-[^']+'/g, `'nonce-${nonce}'`));
response.headers.set('X-Nonce', nonce);
// Other security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// HSTS (only in production)
if (process.env.NODE_ENV === 'production') {
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
return response;
}
// In _document.tsx - use nonce for inline scripts
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
const nonce = ctx.req?.headers['x-nonce'] as string || '';
return { ...initialProps, nonce };
}
render() {
const { nonce } = this.props as { nonce: string };
return (
<Html>
<Head nonce={nonce} />
<body>
<Main />
<NextScript nonce={nonce} />
</body>
</Html>
);
}
}
export default MyDocument;
Cross-Origin Security
CORS Configuration
// src/security/cors.ts
interface CORSConfig {
allowedOrigins: string[] | ((origin: string) => boolean);
allowedMethods: string[];
allowedHeaders: string[];
exposedHeaders: string[];
credentials: boolean;
maxAge: number;
}
const defaultCORSConfig: CORSConfig = {
allowedOrigins: [],
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
credentials: true,
maxAge: 86400, // 24 hours
};
function isOriginAllowed(origin: string, config: CORSConfig): boolean {
if (typeof config.allowedOrigins === 'function') {
return config.allowedOrigins(origin);
}
return config.allowedOrigins.includes(origin);
}
function getCORSHeaders(
request: Request,
config: CORSConfig = defaultCORSConfig
): Headers {
const headers = new Headers();
const origin = request.headers.get('Origin');
if (!origin) {
return headers; // Not a CORS request
}
if (!isOriginAllowed(origin, config)) {
return headers; // Origin not allowed
}
headers.set('Access-Control-Allow-Origin', origin);
if (config.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
// Preflight request
if (request.method === 'OPTIONS') {
headers.set('Access-Control-Allow-Methods', config.allowedMethods.join(', '));
headers.set('Access-Control-Allow-Headers', config.allowedHeaders.join(', '));
headers.set('Access-Control-Max-Age', config.maxAge.toString());
}
if (config.exposedHeaders.length > 0) {
headers.set('Access-Control-Expose-Headers', config.exposedHeaders.join(', '));
}
return headers;
}
// Environment-specific CORS
function createCORSConfig(env: string): CORSConfig {
switch (env) {
case 'development':
return {
...defaultCORSConfig,
allowedOrigins: ['http://localhost:3000', 'http://localhost:3001'],
};
case 'staging':
return {
...defaultCORSConfig,
allowedOrigins: [
'https://staging.example.com',
'https://staging-preview.example.com',
],
};
case 'production':
return {
...defaultCORSConfig,
allowedOrigins: (origin) => {
// Allow main domain and subdomains
const allowedPatterns = [
/^https:\/\/example\.com$/,
/^https:\/\/[a-z0-9-]+\.example\.com$/,
];
return allowedPatterns.some(p => p.test(origin));
},
};
default:
return defaultCORSConfig;
}
}
export { CORSConfig, getCORSHeaders, createCORSConfig };
Input Validation at Each Layer
Validation Pipeline
// src/security/validation.ts
import { z } from 'zod';
// Browser-side validation (UX, not security)
const clientUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
// Edge validation (rate limiting, basic checks)
const edgeRequestSchema = z.object({
headers: z.object({
'content-type': z.string().includes('application/json'),
authorization: z.string().startsWith('Bearer '),
}),
body: z.object({}).passthrough(), // Just check it's valid JSON
});
// Server validation (full security validation)
const serverUserSchema = z.object({
email: z
.string()
.email()
.max(254)
.transform(e => e.toLowerCase().trim()),
password: z
.string()
.min(8)
.max(128)
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Password must contain uppercase, lowercase, and number'),
name: z
.string()
.min(1)
.max(100)
.transform(n => n.trim())
.refine(n => !/<|>|script/i.test(n), 'Invalid characters in name'),
});
// Database validation (constraints)
const databaseUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email().max(254),
passwordHash: z.string().min(60).max(60), // bcrypt hash length
name: z.string().max(100),
createdAt: z.date(),
updatedAt: z.date(),
});
// Validation middleware factory
function createValidationMiddleware<T>(schema: z.Schema<T>) {
return async (request: Request): Promise<{ data: T } | { error: string; details: z.ZodError }> => {
try {
const body = await request.json();
const data = schema.parse(body);
return { data };
} catch (error) {
if (error instanceof z.ZodError) {
return { error: 'Validation failed', details: error };
}
return { error: 'Invalid request body', details: new z.ZodError([]) };
}
};
}
// Sanitization functions
function sanitizeForSQL(input: string): string {
// This is just for logging/debugging - always use parameterized queries!
return input.replace(/['"\\;]/g, '');
}
function sanitizeFilename(filename: string): string {
return filename
.replace(/[^a-zA-Z0-9.-]/g, '_')
.replace(/\.{2,}/g, '.')
.slice(0, 255);
}
function sanitizeForLog(data: Record<string, unknown>): Record<string, unknown> {
const sensitiveFields = ['password', 'token', 'secret', 'apiKey', 'authorization'];
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveFields.some(f => key.toLowerCase().includes(f))) {
result[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null) {
result[key] = sanitizeForLog(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
}
export {
clientUserSchema,
edgeRequestSchema,
serverUserSchema,
databaseUserSchema,
createValidationMiddleware,
sanitizeForSQL,
sanitizeFilename,
sanitizeForLog,
};
Security Headers Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ Security Headers Reference │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ESSENTIAL (Must Have) │
│ ───────────────────── │
│ │
│ Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-X'; │
│ ├── Prevents XSS by controlling script sources │
│ └── Use nonce or hash for inline scripts │
│ │
│ Strict-Transport-Security: max-age=31536000; includeSubDomains; preload │
│ ├── Forces HTTPS │
│ └── Preload for browser list inclusion │
│ │
│ X-Content-Type-Options: nosniff │
│ └── Prevents MIME type sniffing │
│ │
│ X-Frame-Options: DENY │
│ └── Prevents clickjacking (use CSP frame-ancestors instead if possible) │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ RECOMMENDED │
│ ─────────── │
│ │
│ Referrer-Policy: strict-origin-when-cross-origin │
│ └── Controls referrer information sent with requests │
│ │
│ Permissions-Policy: camera=(), microphone=(), geolocation=() │
│ └── Restricts browser feature access │
│ │
│ Cross-Origin-Opener-Policy: same-origin │
│ └── Isolates browsing context │
│ │
│ Cross-Origin-Embedder-Policy: require-corp │
│ └── Required for SharedArrayBuffer (with COOP) │
│ │
│ Cross-Origin-Resource-Policy: same-origin │
│ └── Prevents cross-origin resource loading │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ COOKIES │
│ ─────── │
│ │
│ Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Strict; Path=/ │
│ ├── HttpOnly: Not accessible to JavaScript │
│ ├── Secure: HTTPS only │
│ ├── SameSite=Strict: No cross-site sending │
│ └── Path=/: Scope to root │
│ │
│ For APIs: SameSite=None; Secure (if cross-site cookies needed) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Takeaways
-
Trust nothing from the browser: All client input must be validated server-side; client validation is UX only
-
Edge can validate, not authorize: Edge workers can check token signatures but shouldn't make authorization decisions
-
SSR exposes server context: Filter data before serialization; never send secrets in HTML
-
SSRF is real in SSR: Validate all URLs that servers fetch; block private IP ranges
-
CSP is your XSS firewall: Use strict CSP with nonces; report violations to catch issues early
-
Token rotation prevents theft: Refresh token rotation with family tracking detects replay attacks
-
CORS is not security: CORS protects users, not servers; always validate on the server
-
Validate at every layer: Browser → Edge → Server → Database, each with appropriate checks
-
Headers are defense in depth: Multiple headers work together; don't rely on any single one
-
Log security events: Token refresh, validation failures, CSP violations all need audit trails
Security boundaries define what each layer can and cannot be trusted to do. Design with the assumption that every boundary will be tested by attackers.
What did you think?