Frontend Web Security Architecture: A Production-Grade Guide
Frontend Web Security Architecture: A Production-Grade Guide
Introduction: The Frontend Attack Surface
The frontend is the most exposed part of any web application—it executes untrusted code (JavaScript) in an untrusted environment (the browser) while handling sensitive user data. Modern SPAs have expanded this attack surface significantly:
┌─────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND ATTACK SURFACE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ATTACK VECTORS TARGETS │
│ ────────────── ─────── │
│ • XSS (Reflected, Stored, DOM) • Session tokens │
│ • CSRF • User credentials │
│ • Clickjacking • Personal data (PII) │
│ • Open Redirects • Financial information │
│ • Prototype Pollution • API keys/secrets │
│ • Supply Chain (npm) • Browser storage │
│ • Man-in-the-Middle • DOM manipulation │
│ • CSS Injection • User actions │
│ • Tabnabbing • Clipboard data │
│ • WebSocket hijacking • Camera/microphone │
│ │
│ OWASP TOP 10 (2021) - Frontend Relevant: │
│ ───────────────────────────────────────── │
│ A03: Injection (XSS) A07: Auth Failures │
│ A05: Security Misconfiguration A08: Software Integrity │
│ A06: Vulnerable Components A09: Logging Failures │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
This guide covers production-grade security patterns for modern frontend applications with real code examples, attack demonstrations, and defense implementations.
Part 1: Cross-Site Scripting (XSS)
Understanding XSS Attack Types
XSS allows attackers to execute malicious scripts in victims' browsers. It remains the most prevalent frontend vulnerability.
┌─────────────────────────────────────────────────────────────────────────────┐
│ XSS ATTACK TYPES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. REFLECTED XSS │
│ ┌─────────┐ malicious link ┌─────────┐ reflected ┌─────────┐ │
│ │ Attacker│ ─────────────────▶ │ Victim │ ─────────────▶ │ Server │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ │ │ │
│ │ │◀──── script in response ──│ │
│ │◀─────── stolen data ──────────│ │
│ │
│ 2. STORED XSS │
│ ┌─────────┐ posts malicious ┌─────────┐ stores ┌────────┐ │
│ │ Attacker│ ──────────────────▶ │ Server │ ───────────────▶│Database│ │
│ └─────────┘ comment/post └─────────┘ └────────┘ │
│ │ │ │
│ ┌─────────┐ views page ┌─────────┐ retrieves │ │
│ │ Victim │ ──────────────────▶ │ Server │ ◀─────────────────────│ │
│ └─────────┘ └─────────┘ │
│ │◀──── script executes ─────────│ │
│ │
│ 3. DOM-BASED XSS │
│ ┌─────────┐ ┌─────────┐ │
│ │ Attacker│ crafted URL │ Victim │ (Never touches server) │
│ └─────────┘ ──────────────────▶ └─────────┘ │
│ │ │
│ Client-side JS reads URL/DOM and writes unsafely │
│ │ │
│ Script executes │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
XSS Attack Examples
// VULNERABLE CODE EXAMPLES - DO NOT USE IN PRODUCTION
// 1. Reflected XSS via URL parameter
// URL: https://example.com/search?q=<script>alert(document.cookie)</script>
function VulnerableSearch() {
const params = new URLSearchParams(window.location.search);
const query = params.get('q');
// ❌ VULNERABLE: Direct HTML insertion
return <div dangerouslySetInnerHTML={{ __html: `Results for: ${query}` }} />;
}
// 2. DOM-based XSS via innerHTML
function VulnerableComment({ comment }: { comment: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// ❌ VULNERABLE: innerHTML with user content
ref.current!.innerHTML = comment;
}, [comment]);
return <div ref={ref} />;
}
// 3. XSS via href/src attributes
function VulnerableLink({ url }: { url: string }) {
// ❌ VULNERABLE: javascript: protocol not blocked
// Attacker input: "javascript:alert(document.cookie)"
return <a href={url}>Click here</a>;
}
// 4. XSS via event handlers
function VulnerableAvatar({ imageUrl, onError }: AvatarProps) {
// ❌ VULNERABLE: Attacker-controlled event handler
// Attacker input for onError: "alert(document.cookie)"
return <img src={imageUrl} onError={onError} />;
}
// 5. XSS via JSON injection
function VulnerableEmbed({ userData }: { userData: object }) {
// ❌ VULNERABLE: Script context injection
// If userData contains </script><script>alert(1)</script>
return (
<script
dangerouslySetInnerHTML={{
__html: `window.__DATA__ = ${JSON.stringify(userData)}`
}}
/>
);
}
XSS Defense: Input Sanitization
// DOMPurify - The gold standard for HTML sanitization
import DOMPurify from 'dompurify';
// Configure DOMPurify for strict sanitization
const purifyConfig: DOMPurify.Config = {
// Allowed tags
ALLOWED_TAGS: [
'p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre'
],
// Allowed attributes
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
// Only allow safe href protocols
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// Force target="_blank" links to have rel="noopener"
ADD_ATTR: ['target'],
// Hooks for additional processing
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
};
// Sanitization utility
export function sanitizeHTML(dirty: string): string {
return DOMPurify.sanitize(dirty, purifyConfig);
}
// Hook for safe HTML rendering
export function useSanitizedHTML(html: string): string {
return useMemo(() => sanitizeHTML(html), [html]);
}
// Safe component for user-generated HTML content
interface SafeHTMLProps {
html: string;
className?: string;
allowedTags?: string[];
}
export function SafeHTML({ html, className, allowedTags }: SafeHTMLProps) {
const sanitized = useMemo(() => {
const config = allowedTags
? { ...purifyConfig, ALLOWED_TAGS: allowedTags }
: purifyConfig;
return DOMPurify.sanitize(html, config);
}, [html, allowedTags]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
// Add DOMPurify hooks for extra security
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// Force all links to open in new tab safely
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
// Remove any data-* attributes (potential for attacks)
Array.from(node.attributes).forEach(attr => {
if (attr.name.startsWith('data-')) {
node.removeAttribute(attr.name);
}
});
});
XSS Defense: Context-Aware Output Encoding
// Different contexts require different encoding
// 1. HTML context encoding
export function encodeHTML(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 2. Attribute context encoding
export function encodeAttribute(str: string): string {
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// 3. JavaScript context encoding
export function encodeJavaScript(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/<\//g, '<\\/'); // Prevent </script> injection
}
// 4. URL context encoding
export function encodeURL(str: string): string {
return encodeURIComponent(str);
}
// 5. CSS context encoding
export function encodeCSS(str: string): string {
return str.replace(/[<>"'`\\]/g, char => `\\${char.charCodeAt(0).toString(16)} `);
}
// Safe JSON embedding in script tags
export function safeJSONEmbed(data: unknown): string {
// Encode characters that could break out of script context
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/'/g, '\\u0027')
.replace(/"/g, '\\u0022');
}
// Server-side: Safe data embedding component
function SafeDataEmbed({ data, variableName }: { data: unknown; variableName: string }) {
const safeData = safeJSONEmbed(data);
return (
<script
id="app-data"
type="application/json"
dangerouslySetInnerHTML={{ __html: safeData }}
/>
);
}
// Client-side: Read embedded data safely
function readEmbeddedData<T>(): T {
const script = document.getElementById('app-data');
if (!script) throw new Error('App data not found');
return JSON.parse(script.textContent || '{}');
}
XSS Defense: URL Validation
// Prevent javascript: and data: URL attacks
const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:'];
export function isSafeURL(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return SAFE_URL_PROTOCOLS.includes(parsed.protocol);
} catch {
// Relative URLs are safe
return !url.toLowerCase().startsWith('javascript:') &&
!url.toLowerCase().startsWith('data:') &&
!url.toLowerCase().startsWith('vbscript:');
}
}
export function sanitizeURL(url: string): string {
if (isSafeURL(url)) {
return url;
}
return '#'; // Safe fallback
}
// Safe link component
interface SafeLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
children: React.ReactNode;
}
export function SafeLink({ href, children, ...props }: SafeLinkProps) {
const safeHref = useMemo(() => sanitizeURL(href), [href]);
// External links get security attributes
const isExternal = useMemo(() => {
try {
const url = new URL(href, window.location.origin);
return url.origin !== window.location.origin;
} catch {
return false;
}
}, [href]);
return (
<a
href={safeHref}
{...props}
{...(isExternal && {
target: '_blank',
rel: 'noopener noreferrer',
})}
>
{children}
</a>
);
}
// Safe redirect function
export function safeRedirect(url: string, allowedDomains: string[] = []): void {
try {
const parsed = new URL(url, window.location.origin);
// Check protocol
if (!['http:', 'https:'].includes(parsed.protocol)) {
console.error('Unsafe redirect protocol:', parsed.protocol);
return;
}
// Check domain whitelist for external redirects
if (parsed.origin !== window.location.origin) {
const isAllowed = allowedDomains.some(domain =>
parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)
);
if (!isAllowed) {
console.error('Redirect to non-whitelisted domain:', parsed.hostname);
return;
}
}
window.location.href = url;
} catch (error) {
console.error('Invalid redirect URL:', url);
}
}
Part 2: Content Security Policy (CSP)
Understanding CSP
CSP is a powerful defense-in-depth mechanism that restricts what resources can be loaded and executed.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTENT SECURITY POLICY DIRECTIVES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DIRECTIVE CONTROLS EXAMPLE VALUE │
│ ───────────────────────────────────────────────────────────────────────── │
│ default-src Fallback for other 'self' │
│ script-src JavaScript sources 'self' 'nonce-xxx' │
│ style-src CSS sources 'self' 'unsafe-inline' │
│ img-src Image sources 'self' data: https: │
│ font-src Font sources 'self' https://fonts.com │
│ connect-src XHR/Fetch/WebSocket 'self' https://api.com │
│ frame-src iframe sources 'none' │
│ frame-ancestors Who can embed us 'none' (clickjacking) │
│ form-action Form submission targets 'self' │
│ base-uri <base> tag restrictions 'self' │
│ object-src Plugins (Flash, etc) 'none' │
│ report-uri Violation reporting /csp-report │
│ report-to Reporting API endpoint csp-endpoint │
│ │
│ SOURCE VALUES: │
│ ───────────────────────────────────────────────────────────────────────── │
│ 'self' Same origin │
│ 'none' Block all │
│ 'unsafe-inline' Allow inline (defeats XSS protection!) │
│ 'unsafe-eval' Allow eval() (dangerous!) │
│ 'nonce-{random}' Allow specific inline scripts with nonce │
│ 'sha256-{hash}' Allow scripts matching hash │
│ 'strict-dynamic' Trust scripts loaded by trusted scripts │
│ https: Any HTTPS origin │
│ data: Data URIs │
│ blob: Blob URIs │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Production CSP Implementation
// CSP nonce generation and management
import { randomBytes } from 'crypto';
// Generate cryptographically secure nonce
export function generateNonce(): string {
return randomBytes(16).toString('base64');
}
// CSP configuration builder
interface CSPConfig {
nonce: string;
reportUri?: string;
reportOnly?: boolean;
isDevelopment?: boolean;
}
export function buildCSP(config: CSPConfig): string {
const { nonce, reportUri, reportOnly, isDevelopment } = config;
const directives: Record<string, string[]> = {
'default-src': ["'self'"],
// Scripts: nonce-based for strict CSP
'script-src': [
"'self'",
`'nonce-${nonce}'`,
"'strict-dynamic'", // Allow scripts loaded by nonced scripts
// Fallback for browsers without strict-dynamic support
'https:',
],
// Styles: nonce for inline, self for external
'style-src': [
"'self'",
`'nonce-${nonce}'`,
// Allow inline styles from CSS-in-JS libraries (use hash in production)
...(isDevelopment ? ["'unsafe-inline'"] : []),
],
// Images: self + data URIs for inline images + CDN
'img-src': [
"'self'",
'data:',
'blob:',
'https://cdn.example.com',
'https://*.cloudinary.com',
],
// Fonts: self + Google Fonts + CDN
'font-src': [
"'self'",
'https://fonts.gstatic.com',
'https://cdn.example.com',
],
// API connections
'connect-src': [
"'self'",
'https://api.example.com',
'https://analytics.example.com',
// WebSocket
'wss://ws.example.com',
...(isDevelopment ? ['ws://localhost:*'] : []),
],
// Frames: none by default (clickjacking protection)
'frame-src': ["'none'"],
'frame-ancestors': ["'none'"],
// Form submissions
'form-action': ["'self'"],
// Base URI (prevent base tag injection)
'base-uri': ["'self'"],
// Block plugins
'object-src': ["'none'"],
// Upgrade HTTP to HTTPS
'upgrade-insecure-requests': [],
// Block mixed content
'block-all-mixed-content': [],
};
// Add reporting
if (reportUri) {
directives['report-uri'] = [reportUri];
directives['report-to'] = ['csp-endpoint'];
}
// Build CSP string
const csp = Object.entries(directives)
.map(([directive, values]) =>
values.length > 0 ? `${directive} ${values.join(' ')}` : directive
)
.join('; ');
return csp;
}
// Express middleware for CSP
import { Request, Response, NextFunction } from 'express';
export function cspMiddleware(options: Partial<CSPConfig> = {}) {
return (req: Request, res: Response, next: NextFunction) => {
// Generate nonce per request
const nonce = generateNonce();
// Store nonce for use in templates
res.locals.cspNonce = nonce;
// Build CSP header
const csp = buildCSP({
nonce,
reportUri: '/api/csp-report',
isDevelopment: process.env.NODE_ENV === 'development',
...options,
});
// Set header (use Report-Only in initial rollout)
const headerName = options.reportOnly
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy';
res.setHeader(headerName, csp);
next();
};
}
// CSP violation report handler
interface CSPViolationReport {
'csp-report': {
'document-uri': string;
'referrer': string;
'blocked-uri': string;
'violated-directive': string;
'original-policy': string;
'source-file'?: string;
'line-number'?: number;
'column-number'?: number;
};
}
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body as CSPViolationReport;
// Log violation (filter out noise from browser extensions)
const blockedUri = report['csp-report']['blocked-uri'];
const isExtensionNoise = blockedUri.startsWith('chrome-extension:') ||
blockedUri.startsWith('moz-extension:');
if (!isExtensionNoise) {
console.warn('CSP Violation:', {
documentUri: report['csp-report']['document-uri'],
blockedUri,
violatedDirective: report['csp-report']['violated-directive'],
sourceFile: report['csp-report']['source-file'],
lineNumber: report['csp-report']['line-number'],
});
// Send to monitoring service
sendToMonitoring('csp_violation', report);
}
res.status(204).end();
});
CSP with React/Next.js
// Next.js: Nonce-based CSP
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// CSP header
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https://cdn.example.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
object-src 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set('Content-Security-Policy', cspHeader);
return response;
}
// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = headers().get('x-nonce') || '';
return (
<html lang="en">
<head>
{/* Inline scripts must have nonce */}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `console.log('This script is allowed by CSP')`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
// For styled-components or emotion with nonce
// _document.tsx (Pages Router) or provider setup
import { ServerStyleSheet } from 'styled-components';
export default function Document() {
const nonce = headers().get('x-nonce') || '';
return (
<Html>
<Head nonce={nonce}>
<style nonce={nonce}>{/* critical CSS */}</style>
</Head>
<body>
<Main />
<NextScript nonce={nonce} />
</body>
</Html>
);
}
Part 3: Cross-Site Request Forgery (CSRF)
Understanding CSRF
CSRF tricks authenticated users into performing unwanted actions.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CSRF ATTACK FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User logs into bank.com (session cookie set) │
│ │
│ 2. User visits evil.com (in another tab) │
│ │
│ 3. evil.com contains: │
│ <form action="https://bank.com/transfer" method="POST"> │
│ <input name="to" value="attacker"> │
│ <input name="amount" value="10000"> │
│ </form> │
│ <script>document.forms[0].submit()</script> │
│ │
│ 4. Browser automatically includes bank.com cookies │
│ (because request goes TO bank.com) │
│ │
│ 5. Bank processes transfer (thinks user initiated it) │
│ │
│ WHY IT WORKS: │
│ • Cookies are sent automatically to their origin │
│ • Server can't distinguish legitimate vs forged request │
│ • Same-origin policy doesn't prevent sending requests, only reading │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
CSRF Defense: Token-Based Protection
// Server-side CSRF token generation and validation
import { randomBytes, createHmac } from 'crypto';
const CSRF_SECRET = process.env.CSRF_SECRET!;
const CSRF_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour
interface CSRFToken {
token: string;
timestamp: number;
}
// Generate CSRF token tied to session
export function generateCSRFToken(sessionId: string): string {
const timestamp = Date.now();
const random = randomBytes(16).toString('hex');
// Create token: random + timestamp
const tokenData = `${random}.${timestamp}`;
// Sign with HMAC to prevent tampering
const signature = createHmac('sha256', CSRF_SECRET)
.update(`${sessionId}.${tokenData}`)
.digest('hex');
return `${tokenData}.${signature}`;
}
// Validate CSRF token
export function validateCSRFToken(token: string, sessionId: string): boolean {
try {
const parts = token.split('.');
if (parts.length !== 3) return false;
const [random, timestamp, signature] = parts;
const tokenData = `${random}.${timestamp}`;
// Verify signature
const expectedSignature = createHmac('sha256', CSRF_SECRET)
.update(`${sessionId}.${tokenData}`)
.digest('hex');
// Timing-safe comparison
if (!timingSafeEqual(signature, expectedSignature)) {
return false;
}
// Check expiry
const tokenTime = parseInt(timestamp, 10);
if (Date.now() - tokenTime > CSRF_TOKEN_EXPIRY) {
return false;
}
return true;
} catch {
return false;
}
}
// Timing-safe string comparison
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
// Express middleware
export function csrfProtection() {
return (req: Request, res: Response, next: NextFunction) => {
// Skip for safe methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
// Generate token for forms
const token = generateCSRFToken(req.sessionID);
res.locals.csrfToken = token;
return next();
}
// Validate token for state-changing methods
const token = req.headers['x-csrf-token'] as string ||
req.body?._csrf;
if (!token || !validateCSRFToken(token, req.sessionID)) {
return res.status(403).json({
error: 'CSRF validation failed',
code: 'CSRF_ERROR',
});
}
next();
};
}
CSRF Defense: SameSite Cookies
// Modern CSRF protection with SameSite cookies
// Session cookie configuration
const sessionConfig = {
name: 'session',
secret: process.env.SESSION_SECRET!,
cookie: {
// Strict: Cookie only sent for same-site requests
// Lax: Cookie sent for same-site + top-level navigations
sameSite: 'strict' as const,
// Only send over HTTPS
secure: process.env.NODE_ENV === 'production',
// Prevent JavaScript access
httpOnly: true,
// Set appropriate domain
domain: process.env.COOKIE_DOMAIN,
// Path restriction
path: '/',
// Expiry
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
};
// For APIs: Double Submit Cookie pattern
export function doubleSubmitCookie() {
return (req: Request, res: Response, next: NextFunction) => {
// For GET requests, set the CSRF cookie
if (req.method === 'GET') {
const csrfToken = randomBytes(32).toString('hex');
res.cookie('csrf-token', csrfToken, {
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
httpOnly: false, // Must be readable by JavaScript
path: '/',
});
return next();
}
// For state-changing requests, verify header matches cookie
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
next();
};
}
// React hook for CSRF tokens
function useCSRFToken(): string {
const [token, setToken] = useState<string>('');
useEffect(() => {
// Read from cookie
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='));
if (cookie) {
setToken(cookie.split('=')[1]);
}
}, []);
return token;
}
// API client with CSRF protection
class SecureAPIClient {
private csrfToken: string = '';
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
// Get CSRF token from cookie
this.csrfToken = this.getCSRFToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'X-CSRF-Token': this.csrfToken,
'Content-Type': 'application/json',
},
credentials: 'same-origin', // Include cookies
});
if (!response.ok) {
throw new APIError(response.status, await response.json());
}
return response.json();
}
private getCSRFToken(): string {
const match = document.cookie.match(/csrf-token=([^;]+)/);
return match ? match[1] : '';
}
}
Part 4: Authentication Security
Secure Session Management
// Secure session implementation
interface SessionConfig {
sessionDuration: number;
absoluteTimeout: number;
slidingWindow: boolean;
regenerateOnAuth: boolean;
}
const DEFAULT_SESSION_CONFIG: SessionConfig = {
sessionDuration: 30 * 60 * 1000, // 30 minutes
absoluteTimeout: 8 * 60 * 60 * 1000, // 8 hours max
slidingWindow: true,
regenerateOnAuth: true,
};
class SecureSessionManager {
private config: SessionConfig;
constructor(config: Partial<SessionConfig> = {}) {
this.config = { ...DEFAULT_SESSION_CONFIG, ...config };
}
// Create new session after authentication
async createSession(userId: string, req: Request, res: Response): Promise<Session> {
// Generate secure session ID
const sessionId = await this.generateSecureId();
const session: Session = {
id: sessionId,
userId,
createdAt: Date.now(),
lastActivity: Date.now(),
expiresAt: Date.now() + this.config.sessionDuration,
absoluteExpiry: Date.now() + this.config.absoluteTimeout,
// Bind session to client fingerprint for extra security
fingerprint: this.generateFingerprint(req),
// Track auth events
authEvents: [{
type: 'login',
timestamp: Date.now(),
ip: req.ip,
userAgent: req.headers['user-agent'],
}],
};
// Store session server-side (Redis/database)
await this.storeSession(session);
// Set secure session cookie
this.setSessionCookie(res, sessionId);
return session;
}
// Validate and refresh session
async validateSession(req: Request, res: Response): Promise<Session | null> {
const sessionId = req.cookies['session'];
if (!sessionId) return null;
const session = await this.getSession(sessionId);
if (!session) return null;
// Check expiry
if (Date.now() > session.expiresAt) {
await this.destroySession(sessionId);
return null;
}
// Check absolute timeout
if (Date.now() > session.absoluteExpiry) {
await this.destroySession(sessionId);
return null;
}
// Verify fingerprint (detect session hijacking)
const currentFingerprint = this.generateFingerprint(req);
if (session.fingerprint !== currentFingerprint) {
console.warn('Session fingerprint mismatch', {
sessionId,
expected: session.fingerprint,
actual: currentFingerprint,
});
// Could be suspicious - log but don't immediately invalidate
// (user might have changed networks)
}
// Sliding window: extend expiry on activity
if (this.config.slidingWindow) {
session.lastActivity = Date.now();
session.expiresAt = Date.now() + this.config.sessionDuration;
await this.storeSession(session);
}
return session;
}
// Regenerate session ID (after auth level changes)
async regenerateSession(oldSessionId: string, res: Response): Promise<string> {
const session = await this.getSession(oldSessionId);
if (!session) throw new Error('Session not found');
// Create new session ID
const newSessionId = await this.generateSecureId();
// Update session with new ID
await this.deleteSession(oldSessionId);
session.id = newSessionId;
session.authEvents.push({
type: 'session_regenerated',
timestamp: Date.now(),
});
await this.storeSession(session);
// Update cookie
this.setSessionCookie(res, newSessionId);
return newSessionId;
}
// Destroy session (logout)
async destroySession(sessionId: string, res?: Response): Promise<void> {
await this.deleteSession(sessionId);
if (res) {
res.clearCookie('session', {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
});
}
}
// Destroy all sessions for user (password change, etc.)
async destroyAllUserSessions(userId: string): Promise<void> {
const sessions = await this.getUserSessions(userId);
await Promise.all(sessions.map(s => this.deleteSession(s.id)));
}
private async generateSecureId(): Promise<string> {
return randomBytes(32).toString('hex');
}
private generateFingerprint(req: Request): string {
// Create fingerprint from client characteristics
const components = [
req.headers['user-agent'] || '',
req.headers['accept-language'] || '',
// Don't include IP - too volatile
];
return createHash('sha256')
.update(components.join('|'))
.digest('hex');
}
private setSessionCookie(res: Response, sessionId: string): void {
res.cookie('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: this.config.absoluteTimeout,
});
}
// Implement these with your storage backend (Redis, etc.)
private async storeSession(session: Session): Promise<void> { /* ... */ }
private async getSession(sessionId: string): Promise<Session | null> { /* ... */ }
private async deleteSession(sessionId: string): Promise<void> { /* ... */ }
private async getUserSessions(userId: string): Promise<Session[]> { /* ... */ }
}
JWT Security
// Secure JWT implementation
import { SignJWT, jwtVerify, JWTPayload } from 'jose';
interface TokenConfig {
accessTokenExpiry: string;
refreshTokenExpiry: string;
issuer: string;
audience: string;
}
const JWT_CONFIG: TokenConfig = {
accessTokenExpiry: '15m', // Short-lived
refreshTokenExpiry: '7d', // Longer for refresh
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
};
class JWTService {
private accessSecret: Uint8Array;
private refreshSecret: Uint8Array;
constructor() {
// Use different secrets for access and refresh tokens
this.accessSecret = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET!);
this.refreshSecret = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET!);
}
// Generate token pair
async generateTokens(user: User): Promise<TokenPair> {
const accessToken = await this.generateAccessToken(user);
const refreshToken = await this.generateRefreshToken(user);
return { accessToken, refreshToken };
}
private async generateAccessToken(user: User): Promise<string> {
return new SignJWT({
sub: user.id,
email: user.email,
role: user.role,
// Don't include sensitive data!
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(JWT_CONFIG.issuer)
.setAudience(JWT_CONFIG.audience)
.setExpirationTime(JWT_CONFIG.accessTokenExpiry)
// Add unique identifier for revocation
.setJti(crypto.randomUUID())
.sign(this.accessSecret);
}
private async generateRefreshToken(user: User): Promise<string> {
const tokenId = crypto.randomUUID();
// Store refresh token ID for revocation
await this.storeRefreshToken(tokenId, user.id);
return new SignJWT({
sub: user.id,
type: 'refresh',
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(JWT_CONFIG.issuer)
.setAudience(JWT_CONFIG.audience)
.setExpirationTime(JWT_CONFIG.refreshTokenExpiry)
.setJti(tokenId)
.sign(this.refreshSecret);
}
// Verify access token
async verifyAccessToken(token: string): Promise<JWTPayload> {
try {
const { payload } = await jwtVerify(token, this.accessSecret, {
issuer: JWT_CONFIG.issuer,
audience: JWT_CONFIG.audience,
});
// Check if token is revoked (optional - performance tradeoff)
if (payload.jti && await this.isTokenRevoked(payload.jti)) {
throw new Error('Token revoked');
}
return payload;
} catch (error) {
throw new AuthenticationError('Invalid access token');
}
}
// Refresh token flow
async refreshTokens(refreshToken: string): Promise<TokenPair> {
try {
const { payload } = await jwtVerify(refreshToken, this.refreshSecret, {
issuer: JWT_CONFIG.issuer,
audience: JWT_CONFIG.audience,
});
// Verify it's a refresh token
if (payload.type !== 'refresh') {
throw new Error('Not a refresh token');
}
// Check if refresh token is revoked
if (payload.jti && !(await this.isRefreshTokenValid(payload.jti))) {
throw new Error('Refresh token revoked');
}
// Get user and generate new tokens
const user = await this.getUserById(payload.sub as string);
// Rotate refresh token (revoke old, issue new)
await this.revokeRefreshToken(payload.jti as string);
return this.generateTokens(user);
} catch (error) {
throw new AuthenticationError('Invalid refresh token');
}
}
// Revoke all tokens for user
async revokeAllUserTokens(userId: string): Promise<void> {
await this.revokeUserRefreshTokens(userId);
// Access tokens will expire naturally (short-lived)
// For immediate revocation, maintain a blacklist
}
// Storage methods (implement with Redis/database)
private async storeRefreshToken(tokenId: string, userId: string): Promise<void> { /* ... */ }
private async isRefreshTokenValid(tokenId: string): Promise<boolean> { /* ... */ }
private async revokeRefreshToken(tokenId: string): Promise<void> { /* ... */ }
private async revokeUserRefreshTokens(userId: string): Promise<void> { /* ... */ }
private async isTokenRevoked(tokenId: string): Promise<boolean> { /* ... */ }
private async getUserById(userId: string): Promise<User> { /* ... */ }
}
// Frontend: Secure token storage
class TokenStorage {
// ❌ DON'T store tokens in localStorage (XSS vulnerable)
// ❌ DON'T store tokens in sessionStorage (XSS vulnerable)
// ✅ DO store tokens in httpOnly cookies (server-set)
// ✅ OR use in-memory storage (for SPAs with short sessions)
private accessToken: string | null = null;
setAccessToken(token: string): void {
// In-memory only - lost on refresh but safe from XSS
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
clearTokens(): void {
this.accessToken = null;
}
}
// API client with token refresh
class AuthenticatedAPIClient {
private tokenStorage = new TokenStorage();
private refreshPromise: Promise<void> | null = null;
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
const accessToken = this.tokenStorage.getAccessToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: accessToken ? `Bearer ${accessToken}` : '',
},
credentials: 'include', // Include refresh token cookie
});
// Token expired - try refresh
if (response.status === 401) {
await this.refreshTokens();
return this.request(url, options); // Retry
}
if (!response.ok) {
throw new APIError(response.status, await response.json());
}
return response.json();
}
private async refreshTokens(): Promise<void> {
// Prevent concurrent refresh requests
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = (async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Refresh failed');
}
const { accessToken } = await response.json();
this.tokenStorage.setAccessToken(accessToken);
} catch (error) {
this.tokenStorage.clearTokens();
// Redirect to login
window.location.href = '/login';
} finally {
this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
}
Part 5: Secure Data Handling
Client-Side Storage Security
// Secure storage patterns
// ❌ NEVER store sensitive data in localStorage/sessionStorage
// XSS can read it: localStorage.getItem('token')
// For non-sensitive data that must persist
class SecureLocalStorage {
private prefix = 'app_';
private encryptionKey: CryptoKey | null = null;
async init(userKey: string): Promise<void> {
// Derive encryption key from user-specific data
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(userKey),
'PBKDF2',
false,
['deriveKey']
);
this.encryptionKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode('secure-storage-salt'),
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async setItem(key: string, value: unknown): Promise<void> {
if (!this.encryptionKey) throw new Error('Storage not initialized');
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(value));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey,
encoded
);
// Store IV + encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
localStorage.setItem(
this.prefix + key,
btoa(String.fromCharCode(...combined))
);
}
async getItem<T>(key: string): Promise<T | null> {
if (!this.encryptionKey) throw new Error('Storage not initialized');
const stored = localStorage.getItem(this.prefix + key);
if (!stored) return null;
try {
const combined = new Uint8Array(
atob(stored).split('').map(c => c.charCodeAt(0))
);
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey,
encrypted
);
return JSON.parse(new TextDecoder().decode(decrypted));
} catch {
// Corrupted or tampered - remove it
this.removeItem(key);
return null;
}
}
removeItem(key: string): void {
localStorage.removeItem(this.prefix + key);
}
clear(): void {
Object.keys(localStorage)
.filter(key => key.startsWith(this.prefix))
.forEach(key => localStorage.removeItem(key));
}
}
// IndexedDB with encryption for larger data
class SecureIndexedDB {
private db: IDBDatabase | null = null;
private encryptionKey: CryptoKey | null = null;
private dbName = 'secure-app-db';
private storeName = 'encrypted-store';
async init(userKey: string): Promise<void> {
// Initialize encryption key
await this.initEncryption(userKey);
// Open database
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' });
}
};
});
}
private async initEncryption(userKey: string): Promise<void> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(userKey),
'PBKDF2',
false,
['deriveKey']
);
this.encryptionKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode('indexeddb-salt'),
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async put(id: string, data: unknown): Promise<void> {
if (!this.db || !this.encryptionKey) throw new Error('DB not initialized');
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(data));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey,
encoded
);
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(this.storeName, 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put({
id,
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted)),
});
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async get<T>(id: string): Promise<T | null> {
if (!this.db || !this.encryptionKey) throw new Error('DB not initialized');
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(this.storeName, 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = async () => {
const result = request.result;
if (!result) {
resolve(null);
return;
}
try {
const iv = new Uint8Array(result.iv);
const encrypted = new Uint8Array(result.data);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey!,
encrypted
);
resolve(JSON.parse(new TextDecoder().decode(decrypted)));
} catch {
resolve(null);
}
};
});
}
}
Input Validation
// Comprehensive input validation
import { z } from 'zod';
// Define strict schemas
const userRegistrationSchema = z.object({
email: z.string()
.email('Invalid email format')
.max(254, 'Email too long')
.transform(email => email.toLowerCase().trim()),
password: z.string()
.min(12, 'Password must be at least 12 characters')
.max(128, 'Password too long')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/,
'Password must include uppercase, lowercase, number, and special character'
),
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username too long')
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens')
.transform(username => username.toLowerCase()),
dateOfBirth: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format')
.refine(date => {
const birthDate = new Date(date);
const age = (Date.now() - birthDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
return age >= 13;
}, 'Must be at least 13 years old'),
});
// Validate with detailed error handling
export async function validateRegistration(data: unknown) {
const result = userRegistrationSchema.safeParse(data);
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
throw new ValidationError('Validation failed', errors);
}
return result.data;
}
// React form with validation
function RegistrationForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
const validated = await validateRegistration({
email: formData.get('email'),
password: formData.get('password'),
username: formData.get('username'),
dateOfBirth: formData.get('dateOfBirth'),
});
// Submit validated data
await submitRegistration(validated);
} catch (error) {
if (error instanceof ValidationError) {
const errorMap: Record<string, string> = {};
error.errors.forEach(err => {
errorMap[err.field] = err.message;
});
setErrors(errorMap);
}
}
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
type="email"
autoComplete="email"
// Prevent autofill attacks
readOnly
onFocus={(e) => e.target.removeAttribute('readonly')}
/>
{errors.email && <span className="error">{errors.email}</span>}
{/* ... other fields */}
</form>
);
}
// API input validation middleware
function validateRequest<T extends z.ZodSchema>(schema: T) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
req.body = await schema.parseAsync(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
next(error);
}
};
}
// Usage
app.post(
'/api/users/register',
validateRequest(userRegistrationSchema),
async (req, res) => {
// req.body is now typed and validated
const user = await createUser(req.body);
res.json(user);
}
);
Part 6: Security Headers
Essential Security Headers
// Comprehensive security headers middleware
interface SecurityHeadersConfig {
contentSecurityPolicy?: string;
strictTransportSecurity?: boolean;
frameOptions?: 'DENY' | 'SAMEORIGIN';
contentTypeNosniff?: boolean;
referrerPolicy?: ReferrerPolicy;
permissionsPolicy?: Record<string, string[]>;
}
type ReferrerPolicy =
| 'no-referrer'
| 'no-referrer-when-downgrade'
| 'origin'
| 'origin-when-cross-origin'
| 'same-origin'
| 'strict-origin'
| 'strict-origin-when-cross-origin'
| 'unsafe-url';
const DEFAULT_SECURITY_HEADERS: SecurityHeadersConfig = {
strictTransportSecurity: true,
frameOptions: 'DENY',
contentTypeNosniff: true,
referrerPolicy: 'strict-origin-when-cross-origin',
permissionsPolicy: {
camera: [],
microphone: [],
geolocation: ['self'],
payment: ['self', 'https://payments.example.com'],
},
};
export function securityHeaders(config: SecurityHeadersConfig = {}) {
const settings = { ...DEFAULT_SECURITY_HEADERS, ...config };
return (req: Request, res: Response, next: NextFunction) => {
// HTTP Strict Transport Security
// Forces HTTPS for specified duration
if (settings.strictTransportSecurity) {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
// X-Frame-Options (legacy, use CSP frame-ancestors)
// Prevents clickjacking
if (settings.frameOptions) {
res.setHeader('X-Frame-Options', settings.frameOptions);
}
// X-Content-Type-Options
// Prevents MIME type sniffing
if (settings.contentTypeNosniff) {
res.setHeader('X-Content-Type-Options', 'nosniff');
}
// Referrer-Policy
// Controls referrer information sent with requests
if (settings.referrerPolicy) {
res.setHeader('Referrer-Policy', settings.referrerPolicy);
}
// Permissions-Policy (formerly Feature-Policy)
// Controls browser features
if (settings.permissionsPolicy) {
const policy = Object.entries(settings.permissionsPolicy)
.map(([feature, origins]) => {
if (origins.length === 0) {
return `${feature}=()`;
}
const originList = origins.map(o => o === 'self' ? 'self' : `"${o}"`).join(' ');
return `${feature}=(${originList})`;
})
.join(', ');
res.setHeader('Permissions-Policy', policy);
}
// X-XSS-Protection (legacy, mostly disabled)
// Modern browsers use CSP instead
res.setHeader('X-XSS-Protection', '0');
// X-DNS-Prefetch-Control
// Controls DNS prefetching
res.setHeader('X-DNS-Prefetch-Control', 'off');
// X-Download-Options (IE)
res.setHeader('X-Download-Options', 'noopen');
// X-Permitted-Cross-Domain-Policies (Flash/PDF)
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
// Cross-Origin-Opener-Policy
// Isolates browsing context
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
// Cross-Origin-Embedder-Policy
// Required for SharedArrayBuffer
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
// Cross-Origin-Resource-Policy
// Prevents other origins from loading this resource
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
// Cache-Control for sensitive pages
if (req.path.startsWith('/api/') || req.path.includes('/account')) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
next();
};
}
// Next.js headers configuration
// next.config.js
const securityHeadersNextConfig = [
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(self)',
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'require-corp',
},
];
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeadersNextConfig,
},
];
},
};
Part 7: Subresource Integrity (SRI)
Implementing SRI
// Subresource Integrity protects against CDN compromise
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
// Generate SRI hash for a file
export function generateSRIHash(content: string | Buffer, algorithm: 'sha256' | 'sha384' | 'sha512' = 'sha384'): string {
const hash = createHash(algorithm)
.update(content)
.digest('base64');
return `${algorithm}-${hash}`;
}
// Generate SRI for all static assets during build
interface AssetManifest {
[path: string]: {
url: string;
integrity: string;
};
}
export function generateAssetManifest(assetDir: string): AssetManifest {
const manifest: AssetManifest = {};
// Recursively process all files
const processDirectory = (dir: string) => {
const files = readdirSync(dir);
for (const file of files) {
const fullPath = join(dir, file);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
processDirectory(fullPath);
} else if (/\.(js|css)$/.test(file)) {
const content = readFileSync(fullPath);
const relativePath = relative(assetDir, fullPath);
manifest[relativePath] = {
url: `/static/${relativePath}`,
integrity: generateSRIHash(content),
};
}
}
};
processDirectory(assetDir);
return manifest;
}
// React component for SRI-protected scripts
interface SRIScriptProps {
src: string;
integrity: string;
async?: boolean;
defer?: boolean;
}
function SRIScript({ src, integrity, async, defer }: SRIScriptProps) {
return (
<script
src={src}
integrity={integrity}
crossOrigin="anonymous" // Required for SRI
async={async}
defer={defer}
/>
);
}
// SRI-protected stylesheet
interface SRIStylesheetProps {
href: string;
integrity: string;
}
function SRIStylesheet({ href, integrity }: SRIStylesheetProps) {
return (
<link
rel="stylesheet"
href={href}
integrity={integrity}
crossOrigin="anonymous"
/>
);
}
// Webpack plugin for automatic SRI
// webpack.config.js
const SriPlugin = require('webpack-subresource-integrity');
module.exports = {
output: {
crossOriginLoading: 'anonymous',
},
plugins: [
new SriPlugin({
hashFuncNames: ['sha384'],
enabled: process.env.NODE_ENV === 'production',
}),
],
};
// Verify SRI at runtime (for dynamically loaded scripts)
async function loadScriptWithSRI(url: string, expectedHash: string): Promise<void> {
const response = await fetch(url);
const content = await response.text();
// Calculate hash
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-384', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashBase64 = btoa(String.fromCharCode(...hashArray));
const actualHash = `sha384-${hashBase64}`;
if (actualHash !== expectedHash) {
throw new Error(`SRI hash mismatch for ${url}`);
}
// Safe to execute
const script = document.createElement('script');
script.textContent = content;
document.head.appendChild(script);
}
Part 8: Supply Chain Security
NPM Package Security
// Protecting against malicious packages
// 1. Lock file integrity
// Always commit package-lock.json / yarn.lock / pnpm-lock.yaml
// 2. Audit dependencies regularly
// package.json scripts
{
"scripts": {
"security:audit": "npm audit --production",
"security:audit:fix": "npm audit fix",
"security:check": "npx better-npm-audit audit"
}
}
// 3. Pre-install hook to verify packages
// .npmrc
//save-exact=true
//ignore-scripts=true // Disable postinstall scripts (can be malicious)
// 4. Use package.json overrides for vulnerable transitive deps
{
"overrides": {
"vulnerable-package": "^2.0.0"
}
}
// 5. CI/CD security checks
// .github/workflows/security.yml
/*
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm audit --audit-level=high
- name: Snyk Security Scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
*/
// 6. Runtime detection of prototype pollution
function freezePrototypes() {
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
Object.freeze(String.prototype);
Object.freeze(Number.prototype);
Object.freeze(Boolean.prototype);
}
// 7. Monitor for new vulnerabilities
// package.json
{
"scripts": {
"postinstall": "npm audit --json > audit-report.json || true"
}
}
// 8. Safe package import pattern
// Avoid dynamic requires/imports with user input
// ❌ DANGEROUS
const module = await import(userInput);
// ✅ SAFE: Whitelist allowed modules
const ALLOWED_MODULES = {
'charts': () => import('./charts'),
'forms': () => import('./forms'),
'tables': () => import('./tables'),
};
async function loadModule(name: string) {
const loader = ALLOWED_MODULES[name];
if (!loader) {
throw new Error(`Unknown module: ${name}`);
}
return loader();
}
Part 9: Security Monitoring & Logging
Client-Side Security Monitoring
// Comprehensive security event monitoring
interface SecurityEvent {
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
message: string;
metadata: Record<string, unknown>;
timestamp: number;
url: string;
userAgent: string;
sessionId?: string;
}
class SecurityMonitor {
private events: SecurityEvent[] = [];
private flushInterval: number = 10000; // 10 seconds
private maxEvents: number = 100;
constructor() {
this.initializeMonitoring();
this.startPeriodicFlush();
}
private initializeMonitoring() {
// Monitor CSP violations
document.addEventListener('securitypolicyviolation', (e) => {
this.logEvent({
type: 'csp_violation',
severity: 'high',
message: `CSP violation: ${e.violatedDirective}`,
metadata: {
blockedURI: e.blockedURI,
violatedDirective: e.violatedDirective,
originalPolicy: e.originalPolicy,
sourceFile: e.sourceFile,
lineNumber: e.lineNumber,
},
});
});
// Monitor unhandled promise rejections
window.addEventListener('unhandledrejection', (e) => {
if (this.isSecurityRelated(e.reason)) {
this.logEvent({
type: 'unhandled_security_error',
severity: 'medium',
message: e.reason?.message || 'Unknown error',
metadata: {
stack: e.reason?.stack,
},
});
}
});
// Monitor suspicious DOM modifications
this.monitorDOMChanges();
// Monitor suspicious network requests
this.monitorNetworkRequests();
// Detect debugging/tampering
this.detectTampering();
}
private monitorDOMChanges() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof HTMLScriptElement) {
// Script added dynamically
if (!node.nonce && node.src) {
this.logEvent({
type: 'suspicious_script_injection',
severity: 'critical',
message: 'Script added without nonce',
metadata: {
src: node.src,
innerHTML: node.innerHTML?.substring(0, 200),
},
});
}
}
if (node instanceof HTMLIFrameElement) {
this.logEvent({
type: 'iframe_injection',
severity: 'high',
message: 'IFrame added dynamically',
metadata: {
src: node.src,
},
});
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
private monitorNetworkRequests() {
// Override fetch
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.url;
// Log requests to unusual domains
try {
const parsedUrl = new URL(url, window.location.origin);
if (this.isSuspiciousDomain(parsedUrl.hostname)) {
this.logEvent({
type: 'suspicious_request',
severity: 'high',
message: `Request to suspicious domain: ${parsedUrl.hostname}`,
metadata: { url, method: init?.method || 'GET' },
});
}
} catch {}
return originalFetch(input, init);
};
// Override XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method: string, url: string, ...args: any[]) {
try {
const parsedUrl = new URL(url, window.location.origin);
if (this.isSuspiciousDomain(parsedUrl.hostname)) {
securityMonitor.logEvent({
type: 'suspicious_xhr',
severity: 'high',
message: `XHR to suspicious domain: ${parsedUrl.hostname}`,
metadata: { url, method },
});
}
} catch {}
return originalOpen.call(this, method, url, ...args);
};
}
private detectTampering() {
// Detect if DevTools is open (basic check)
const devtools = {
isOpen: false,
orientation: undefined as string | undefined,
};
const threshold = 160;
const emitEvent = () => {
if (!devtools.isOpen) {
this.logEvent({
type: 'devtools_opened',
severity: 'low',
message: 'Developer tools opened',
metadata: { orientation: devtools.orientation },
});
}
devtools.isOpen = true;
};
setInterval(() => {
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
if (widthThreshold || heightThreshold) {
emitEvent();
} else {
devtools.isOpen = false;
}
}, 500);
}
private isSecurityRelated(error: any): boolean {
if (!error) return false;
const securityKeywords = ['csrf', 'xss', 'auth', 'token', 'session', 'forbidden', 'unauthorized'];
const message = (error.message || '').toLowerCase();
return securityKeywords.some(keyword => message.includes(keyword));
}
private isSuspiciousDomain(hostname: string): boolean {
// Check against known malicious domains or unexpected domains
const trustedDomains = [
'example.com',
'api.example.com',
'cdn.example.com',
'analytics.example.com',
];
return !trustedDomains.some(domain =>
hostname === domain || hostname.endsWith(`.${domain}`)
);
}
logEvent(event: Omit<SecurityEvent, 'timestamp' | 'url' | 'userAgent'>) {
const fullEvent: SecurityEvent = {
...event,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent,
sessionId: this.getSessionId(),
};
this.events.push(fullEvent);
// Immediately flush critical events
if (event.severity === 'critical') {
this.flush();
}
// Prevent memory leak
if (this.events.length > this.maxEvents) {
this.flush();
}
}
private flush() {
if (this.events.length === 0) return;
const eventsToSend = [...this.events];
this.events = [];
// Use sendBeacon for reliable delivery
navigator.sendBeacon(
'/api/security/events',
JSON.stringify(eventsToSend)
);
}
private startPeriodicFlush() {
setInterval(() => this.flush(), this.flushInterval);
window.addEventListener('beforeunload', () => this.flush());
}
private getSessionId(): string | undefined {
// Get from your session management
return undefined;
}
}
// Initialize global monitor
const securityMonitor = new SecurityMonitor();
export { securityMonitor };
Part 10: Security Checklist
Pre-Production Security Checklist
## Authentication & Sessions
- [ ] Passwords hashed with bcrypt/Argon2 (cost factor ≥ 12)
- [ ] Session tokens are cryptographically random (≥ 128 bits)
- [ ] Sessions expire after inactivity and have absolute timeout
- [ ] Session regeneration on authentication level change
- [ ] Secure cookie attributes: HttpOnly, Secure, SameSite=Strict
- [ ] Multi-factor authentication available for sensitive accounts
- [ ] Account lockout after failed attempts (with notification)
- [ ] Password reset tokens are single-use and expire quickly
## XSS Prevention
- [ ] User input sanitized with DOMPurify before HTML rendering
- [ ] Context-aware output encoding (HTML, JS, URL, CSS)
- [ ] CSP implemented with nonce or hash-based script allowlisting
- [ ] No use of dangerous functions: innerHTML, eval(), document.write()
- [ ] URL validation blocks javascript: and data: protocols
- [ ] JSON data properly escaped when embedded in HTML
## CSRF Protection
- [ ] Anti-CSRF tokens for state-changing operations
- [ ] SameSite=Strict on session cookies
- [ ] Token validation on server-side
- [ ] Tokens are unique per session
## Security Headers
- [ ] Content-Security-Policy (strict, nonce-based)
- [ ] Strict-Transport-Security (HSTS with preload)
- [ ] X-Frame-Options: DENY (or CSP frame-ancestors)
- [ ] X-Content-Type-Options: nosniff
- [ ] Referrer-Policy: strict-origin-when-cross-origin
- [ ] Permissions-Policy restricting dangerous features
## Data Protection
- [ ] HTTPS enforced everywhere (HSTS preload)
- [ ] Sensitive data not stored in localStorage/sessionStorage
- [ ] Client-side encryption for sensitive offline data
- [ ] No secrets in client-side code or logs
- [ ] PII minimized and properly protected
## Input Validation
- [ ] All input validated server-side (client-side is UX only)
- [ ] Strict schema validation (Zod/Joi/Yup)
- [ ] File uploads validated (type, size, content)
- [ ] Redirect URLs validated against whitelist
## Dependencies
- [ ] Regular npm audit with no high/critical issues
- [ ] Lockfile committed and integrity verified
- [ ] Automated vulnerability scanning in CI/CD
- [ ] No packages with known security issues
- [ ] SRI hashes for CDN-loaded scripts
## Logging & Monitoring
- [ ] Security events logged (auth failures, CSRF, CSP violations)
- [ ] No sensitive data in logs (passwords, tokens, PII)
- [ ] Real-time alerting for critical security events
- [ ] Audit trail for sensitive operations
## Error Handling
- [ ] Generic error messages to users (no stack traces)
- [ ] Detailed errors logged server-side only
- [ ] Custom error pages (no framework defaults)
## API Security
- [ ] Rate limiting implemented
- [ ] Request size limits enforced
- [ ] CORS properly configured (not *)
- [ ] GraphQL query depth/complexity limits
Conclusion
Frontend security is not optional—it's a critical engineering discipline. Key takeaways:
-
Defense in Depth: Never rely on a single security measure. Layer defenses: CSP + input sanitization + output encoding.
-
Trust No Input: All user input, URL parameters, localStorage data, and even data from your own API should be validated and sanitized.
-
Secure by Default: Make the secure path the easy path. Use frameworks and libraries that handle security correctly out of the box.
-
Monitor Everything: You can't protect what you can't see. Implement comprehensive security monitoring and alerting.
-
Keep Updated: Security is a moving target. Regularly audit dependencies, review new vulnerability disclosures, and update defenses accordingly.
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECURITY DEFENSE LAYERS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Network │
│ ├── HTTPS everywhere (HSTS preload) │
│ ├── Certificate pinning (mobile) │
│ └── Rate limiting │
│ │
│ Layer 2: HTTP Headers │
│ ├── Content-Security-Policy │
│ ├── Strict-Transport-Security │
│ ├── X-Frame-Options │
│ └── Permissions-Policy │
│ │
│ Layer 3: Authentication │
│ ├── Secure session management │
│ ├── JWT best practices │
│ ├── CSRF tokens │
│ └── MFA │
│ │
│ Layer 4: Application │
│ ├── Input validation │
│ ├── Output encoding │
│ ├── HTML sanitization │
│ └── URL validation │
│ │
│ Layer 5: Monitoring │
│ ├── Security event logging │
│ ├── CSP violation reports │
│ ├── Anomaly detection │
│ └── Incident response │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Security vulnerabilities in production are not just technical failures—they're trust failures. Build security into your frontend from day one.
What did you think?