# Backend Security Practices Security patterns and OWASP Top 10 mitigations for Node.js/Express applications. ## Guide Index 1. [OWASP Top 10 Mitigations](#1-owasp-top-10-mitigations) 2. [Input Validation](#2-input-validation) 3. [SQL Injection Prevention](#3-sql-injection-prevention) 4. [XSS Prevention](#4-xss-prevention) 5. [Authentication Security](#5-authentication-security) 6. [Authorization Patterns](#6-authorization-patterns) 7. [Security Headers](#7-security-headers) 8. [Secrets Management](#8-secrets-management) 9. [Logging and Monitoring](#9-logging-and-monitoring) --- ## 1. OWASP Top 10 Mitigations ### A01: Broken Access Control ```typescript // BAD: Direct object reference app.get('/users/:id/profile', async (req, res) => { const user = await db.users.findById(req.params.id); res.json(user); // Anyone can access any user! }); // GOOD: Verify ownership app.get('/users/:id/profile', authenticate, async (req, res) => { const userId = req.params.id; // Verify user can only access their own data if (req.user.id !== userId && !req.user.roles.includes('admin')) { return res.status(403).json({ error: { code: 'FORBIDDEN' } }); } const user = await db.users.findById(userId); res.json(user); }); ``` ### A02: Cryptographic Failures ```typescript // BAD: Weak hashing const hash = crypto.createHash('md5').update(password).digest('hex'); // GOOD: bcrypt with appropriate cost factor import bcrypt from 'bcrypt'; const SALT_ROUNDS = 12; // Adjust based on hardware async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } ``` ### A03: Injection ```typescript // BAD: String concatenation in SQL const query = `SELECT * FROM users WHERE email = '${email}'`; // GOOD: Parameterized queries const result = await db.query( 'SELECT * FROM users WHERE email = $1', [email] ); ``` ### A04: Insecure Design ```typescript // BAD: No rate limiting on sensitive operations app.post('/forgot-password', async (req, res) => { await sendResetEmail(req.body.email); res.json({ message: 'If email exists, reset link sent' }); }); // GOOD: Rate limit + consistent response time import rateLimit from 'express-rate-limit'; const passwordResetLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 3, // 3 attempts per 15 minutes skipSuccessfulRequests: false, }); app.post('/forgot-password', passwordResetLimiter, async (req, res) => { const startTime = Date.now(); try { const user = await db.users.findByEmail(req.body.email); if (user) { await sendResetEmail(user.email); } } catch (err) { logger.error(err); } // Consistent response time prevents timing attacks const elapsed = Date.now() - startTime; const minDelay = 500; if (elapsed < minDelay) { await sleep(minDelay - elapsed); } // Same response regardless of email existence res.json({ message: 'If email exists, reset link sent' }); }); ``` ### A05: Security Misconfiguration ```typescript // BAD: Detailed errors in production app.use((err, req, res, next) => { res.status(500).json({ error: err.message, stack: err.stack, // Exposes internals! }); }); // GOOD: Environment-aware error handling app.use((err: Error, req: Request, res: Response, next: NextFunction) => { const requestId = req.id; // Always log full error internally logger.error({ err, requestId }, 'Unhandled error'); // Return safe response res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred', requestId, }, }); }); ``` ### A06: Vulnerable Components ```bash # Check for vulnerabilities npm audit # Fix automatically where possible npm audit fix # Check specific package npm audit --package-lock-only # Use Snyk for deeper analysis npx snyk test ``` ```typescript // Automated dependency updates (package.json) { "scripts": { "security:audit": "npm audit --audit-level=high", "security:check": "snyk test", "preinstall": "npm audit" } } ``` ### A07: Authentication Failures ```typescript // BAD: Weak session management app.post('/login', async (req, res) => { const user = await authenticate(req.body); req.session.userId = user.id; // Session fixation risk res.json({ success: true }); }); // GOOD: Regenerate session on authentication app.post('/login', async (req, res) => { const user = await authenticate(req.body); // Regenerate session to prevent fixation req.session.regenerate((err) => { if (err) return next(err); req.session.userId = user.id; req.session.createdAt = Date.now(); req.session.save((err) => { if (err) return next(err); res.json({ success: true }); }); }); }); ``` ### A08: Software and Data Integrity Failures ```typescript // Verify webhook signatures (e.g., Stripe) import Stripe from 'stripe'; app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature'] as string; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body, sig, endpointSecret ); } catch (err) { logger.warn({ err }, 'Webhook signature verification failed'); return res.status(400).json({ error: 'Invalid signature' }); } // Process verified event await handleStripeEvent(event); res.json({ received: true }); } ); ``` ### A09: Security Logging Failures ```typescript // Comprehensive security logging import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', redact: ['req.headers.authorization', 'req.body.password'], // Redact sensitive }); // Log security events function logSecurityEvent(event: { type: 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'ACCESS_DENIED' | 'SUSPICIOUS_ACTIVITY'; userId?: string; ip: string; userAgent: string; details?: Record; }) { logger.info({ security: true, ...event, timestamp: new Date().toISOString(), }, `Security event: ${event.type}`); } // Usage app.post('/login', async (req, res) => { try { const user = await authenticate(req.body); logSecurityEvent({ type: 'LOGIN_SUCCESS', userId: user.id, ip: req.ip, userAgent: req.headers['user-agent'] || '', }); // ... } catch (err) { logSecurityEvent({ type: 'LOGIN_FAILURE', ip: req.ip, userAgent: req.headers['user-agent'] || '', details: { email: req.body.email }, }); // ... } }); ``` ### A10: Server-Side Request Forgery (SSRF) ```typescript // BAD: Unvalidated URL fetch app.post('/fetch-url', async (req, res) => { const response = await fetch(req.body.url); // SSRF vulnerability! res.json({ data: await response.text() }); }); // GOOD: URL allowlist and validation import { URL } from 'url'; const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com']; function isAllowedUrl(urlString: string): boolean { try { const url = new URL(urlString); // Block internal IPs const blockedPatterns = [ /^localhost$/i, /^127\./, /^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^192\.168\./, /^0\./, /^169\.254\./, /^\[::1\]$/, /^metadata\.google\.internal$/, /^169\.254\.169\.254$/, ]; if (blockedPatterns.some(p => p.test(url.hostname))) { return false; } // Only allow HTTPS if (url.protocol !== 'https:') { return false; } // Check allowlist return ALLOWED_HOSTS.includes(url.hostname); } catch { return false; } } app.post('/fetch-url', async (req, res) => { const { url } = req.body; if (!isAllowedUrl(url)) { return res.status(400).json({ error: { code: 'INVALID_URL' } }); } const response = await fetch(url, { timeout: 5000, follow: 0, // Don't follow redirects }); res.json({ data: await response.text() }); }); ``` --- ## 2. Input Validation ### Schema Validation with Zod ```typescript import { z } from 'zod'; // Define schemas const CreateUserSchema = z.object({ email: z.string().email().max(255).toLowerCase(), password: z.string() .min(8, 'Password must be at least 8 characters') .max(72, 'Password must be at most 72 characters') // bcrypt limit .regex(/[A-Z]/, 'Password must contain uppercase letter') .regex(/[a-z]/, 'Password must contain lowercase letter') .regex(/[0-9]/, 'Password must contain number'), name: z.string().min(1).max(100).trim(), age: z.number().int().min(18).max(120).optional(), }); const PaginationSchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), sort: z.enum(['asc', 'desc']).default('desc'), }); // Validation middleware function validate(schema: z.ZodSchema) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body); if (!result.success) { const details = result.error.errors.map(err => ({ field: err.path.join('.'), code: err.code, message: err.message, })); return res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Request validation failed', details, }, }); } req.body = result.data; next(); }; } // Usage app.post('/users', validate(CreateUserSchema), async (req, res) => { // req.body is now typed and validated const user = await userService.create(req.body); res.status(201).json(user); }); ``` ### Sanitization ```typescript import DOMPurify from 'isomorphic-dompurify'; import xss from 'xss'; // HTML sanitization for rich text fields function sanitizeHtml(dirty: string): string { return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], ALLOWED_ATTR: ['href'], }); } // Plain text sanitization (strip all HTML) function sanitizePlainText(dirty: string): string { return xss(dirty, { whiteList: {}, stripIgnoreTag: true, stripIgnoreTagBody: ['script'], }); } // File path sanitization import path from 'path'; function sanitizePath(userPath: string, baseDir: string): string | null { const resolved = path.resolve(baseDir, userPath); // Prevent directory traversal if (!resolved.startsWith(baseDir)) { return null; } return resolved; } ``` --- ## 3. SQL Injection Prevention ### Parameterized Queries ```typescript // BAD: String interpolation const email = "'; DROP TABLE users; --"; db.query(`SELECT * FROM users WHERE email = '${email}'`); // GOOD: Parameterized query (pg) const result = await db.query( 'SELECT * FROM users WHERE email = $1', [email] ); // GOOD: Parameterized query (mysql2) const [rows] = await connection.execute( 'SELECT * FROM users WHERE email = ?', [email] ); ``` ### Query Builders ```typescript // Using Knex.js const users = await knex('users') .where('email', email) // Automatically parameterized .andWhere('status', 'active') .select('id', 'name', 'email'); // Dynamic WHERE with safe column names const ALLOWED_COLUMNS = ['name', 'email', 'created_at'] as const; function buildUserQuery(filters: Record) { let query = knex('users').select('id', 'name', 'email'); for (const [column, value] of Object.entries(filters)) { // Validate column name against allowlist if (ALLOWED_COLUMNS.includes(column as any)) { query = query.where(column, value); } } return query; } ``` ### ORM Safety ```typescript // Prisma (safe by default) const user = await prisma.user.findUnique({ where: { email }, // Automatically escaped }); // TypeORM (safe by default) const user = await userRepository.findOne({ where: { email }, // Automatically escaped }); // DANGER: Raw queries still require parameterization // BAD await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`); // GOOD await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`; ``` --- ## 4. XSS Prevention ### Output Encoding ```typescript // Server-side template rendering (EJS) // In template: <%= userInput %> (escaped) // NOT: <%- userInput %> (raw, dangerous) // Manual HTML encoding function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // JSON response (automatically safe in modern frameworks) res.json({ message: userInput }); // JSON.stringify escapes by default ``` ### Content Security Policy ```typescript import helmet from 'helmet'; app.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'strict-dynamic'"], styleSrc: ["'self'", "'unsafe-inline'"], // Consider using nonces imgSrc: ["'self'", "data:", "https:"], fontSrc: ["'self'"], objectSrc: ["'none'"], frameAncestors: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], upgradeInsecureRequests: [], }, })); ``` ### API Response Safety ```typescript // Set correct Content-Type for JSON APIs app.use((req, res, next) => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('X-Content-Type-Options', 'nosniff'); next(); }); // Disable JSONP (if not needed) // Don't implement callback parameter handling // Safe JSON response res.json({ data: sanitizedData, // Never reflect raw user input }); ``` --- ## 5. Authentication Security ### Password Storage ```typescript import bcrypt from 'bcrypt'; import { randomBytes } from 'crypto'; const SALT_ROUNDS = 12; async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } // For password reset tokens function generateSecureToken(): string { return randomBytes(32).toString('hex'); } // Token expiration (store in DB) interface PasswordResetToken { token: string; // Hashed userId: string; expiresAt: Date; // 1 hour from creation } ``` ### JWT Best Practices ```typescript import jwt from 'jsonwebtoken'; // Use asymmetric keys in production const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!; const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!; interface AccessTokenPayload { sub: string; // User ID email: string; roles: string[]; iat: number; exp: number; } function generateAccessToken(user: User): string { const payload: Omit = { sub: user.id, email: user.email, roles: user.roles, }; return jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '15m', issuer: 'api.example.com', audience: 'example.com', }); } function verifyAccessToken(token: string): AccessTokenPayload { return jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'], issuer: 'api.example.com', audience: 'example.com', }) as AccessTokenPayload; } // Refresh tokens should be stored in DB and rotated interface RefreshToken { id: string; token: string; // Hashed userId: string; expiresAt: Date; family: string; // For rotation detection isRevoked: boolean; } ``` ### Session Management ```typescript import session from 'express-session'; import RedisStore from 'connect-redis'; import { createClient } from 'redis'; const redisClient = createClient({ url: process.env.REDIS_URL }); app.use(session({ store: new RedisStore({ client: redisClient }), name: 'sessionId', // Don't use default 'connect.sid' secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000, // 24 hours domain: process.env.COOKIE_DOMAIN, }, })); // Regenerate session on privilege change async function elevateSession(req: Request): Promise { return new Promise((resolve, reject) => { const userId = req.session.userId; req.session.regenerate((err) => { if (err) return reject(err); req.session.userId = userId; req.session.elevated = true; req.session.elevatedAt = Date.now(); resolve(); }); }); } ``` --- ## 6. Authorization Patterns ### Role-Based Access Control (RBAC) ```typescript type Role = 'user' | 'moderator' | 'admin'; type Permission = 'read:users' | 'write:users' | 'delete:users' | 'read:admin'; const ROLE_PERMISSIONS: Record = { user: ['read:users'], moderator: ['read:users', 'write:users'], admin: ['read:users', 'write:users', 'delete:users', 'read:admin'], }; function hasPermission(userRoles: Role[], required: Permission): boolean { return userRoles.some(role => ROLE_PERMISSIONS[role]?.includes(required) ); } // Middleware function requirePermission(permission: Permission) { return (req: Request, res: Response, next: NextFunction) => { if (!hasPermission(req.user.roles, permission)) { return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }, }); } next(); }; } // Usage app.delete('/users/:id', authenticate, requirePermission('delete:users'), deleteUserHandler ); ``` ### Attribute-Based Access Control (ABAC) ```typescript interface AccessContext { user: { id: string; roles: string[]; department: string }; resource: { ownerId: string; department: string; sensitivity: string }; action: 'read' | 'write' | 'delete'; environment: { time: Date; ip: string }; } interface Policy { name: string; condition: (ctx: AccessContext) => boolean; } const policies: Policy[] = [ { name: 'owner-full-access', condition: (ctx) => ctx.resource.ownerId === ctx.user.id, }, { name: 'same-department-read', condition: (ctx) => ctx.action === 'read' && ctx.resource.department === ctx.user.department, }, { name: 'admin-override', condition: (ctx) => ctx.user.roles.includes('admin'), }, { name: 'no-sensitive-outside-hours', condition: (ctx) => { const hour = ctx.environment.time.getHours(); return ctx.resource.sensitivity !== 'high' || (hour >= 9 && hour <= 17); }, }, ]; function evaluateAccess(ctx: AccessContext): boolean { return policies.some(policy => policy.condition(ctx)); } ``` --- ## 7. Security Headers ### Complete Helmet Configuration ```typescript import helmet from 'helmet'; app.use(helmet({ // Content Security Policy contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'", "https://api.example.com"], fontSrc: ["'self'"], objectSrc: ["'none'"], mediaSrc: ["'none'"], frameSrc: ["'none'"], }, }, // Strict Transport Security hsts: { maxAge: 31536000, includeSubDomains: true, preload: true, }, // Prevent clickjacking frameguard: { action: 'deny' }, // Prevent MIME sniffing noSniff: true, // XSS filter (legacy browsers) xssFilter: true, // Hide X-Powered-By hidePoweredBy: true, // Referrer policy referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, // Cross-origin policies crossOriginEmbedderPolicy: false, // Enable if using SharedArrayBuffer crossOriginOpenerPolicy: { policy: 'same-origin' }, crossOriginResourcePolicy: { policy: 'same-origin' }, })); // CORS configuration import cors from 'cors'; app.use(cors({ origin: ['https://example.com', 'https://app.example.com'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 86400, // 24 hours })); ``` ### Header Reference | Header | Purpose | Value | |--------|---------|-------| | `Strict-Transport-Security` | Force HTTPS | `max-age=31536000; includeSubDomains; preload` | | `Content-Security-Policy` | Prevent XSS | See above | | `X-Content-Type-Options` | Prevent MIME sniffing | `nosniff` | | `X-Frame-Options` | Prevent clickjacking | `DENY` | | `Referrer-Policy` | Control referrer info | `strict-origin-when-cross-origin` | | `Permissions-Policy` | Feature restrictions | `geolocation=(), microphone=()` | --- ## 8. Secrets Management ### Environment Variables ```typescript // config/secrets.ts import { z } from 'zod'; const SecretsSchema = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), JWT_PRIVATE_KEY: z.string(), JWT_PUBLIC_KEY: z.string(), REDIS_URL: z.string().url(), STRIPE_SECRET_KEY: z.string().startsWith('sk_'), STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'), }); // Validate on startup export const secrets = SecretsSchema.parse(process.env); // NEVER log secrets console.log('Config loaded:', { database: secrets.DATABASE_URL.replace(/\/\/.*@/, '//***@'), redis: 'configured', stripe: 'configured', }); ``` ### Secret Rotation ```typescript // Support multiple keys during rotation const JWT_SECRETS = [ process.env.JWT_SECRET_CURRENT!, process.env.JWT_SECRET_PREVIOUS!, // Keep for grace period ].filter(Boolean); function verifyTokenWithRotation(token: string): TokenPayload | null { for (const secret of JWT_SECRETS) { try { return jwt.verify(token, secret) as TokenPayload; } catch { continue; } } return null; } ``` ### Vault Integration ```typescript import Vault from 'node-vault'; const vault = Vault({ endpoint: process.env.VAULT_ADDR, token: process.env.VAULT_TOKEN, }); async function getSecret(path: string): Promise { const result = await vault.read(`secret/data/${path}`); return result.data.data.value; } // Cache secrets with TTL const secretsCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes async function getCachedSecret(path: string): Promise { const cached = secretsCache.get(path); if (cached && cached.expiresAt > Date.now()) { return cached.value; } const value = await getSecret(path); secretsCache.set(path, { value, expiresAt: Date.now() + CACHE_TTL }); return value; } ``` --- ## 9. Logging and Monitoring ### Security Event Logging ```typescript import pino from 'pino'; const logger = pino({ level: 'info', redact: { paths: [ 'req.headers.authorization', 'req.headers.cookie', 'req.body.password', 'req.body.token', '*.password', '*.secret', '*.apiKey', ], censor: '[REDACTED]', }, }); // Security event types type SecurityEventType = | 'AUTH_SUCCESS' | 'AUTH_FAILURE' | 'AUTH_LOCKOUT' | 'PASSWORD_CHANGED' | 'PASSWORD_RESET_REQUEST' | 'PERMISSION_DENIED' | 'RATE_LIMIT_EXCEEDED' | 'SUSPICIOUS_ACTIVITY' | 'TOKEN_REVOKED'; interface SecurityEvent { type: SecurityEventType; userId?: string; ip: string; userAgent: string; path: string; details?: Record; } function logSecurityEvent(event: SecurityEvent): void { logger.info({ security: true, ...event, timestamp: new Date().toISOString(), }, `Security: ${event.type}`); } ``` ### Request Logging ```typescript import pinoHttp from 'pino-http'; app.use(pinoHttp({ logger, genReqId: (req) => req.headers['x-request-id'] || crypto.randomUUID(), serializers: { req: (req) => ({ id: req.id, method: req.method, url: req.url, remoteAddress: req.remoteAddress, // Don't log headers by default (may contain sensitive data) }), res: (res) => ({ statusCode: res.statusCode, }), }, customLogLevel: (req, res, err) => { if (res.statusCode >= 500 || err) return 'error'; if (res.statusCode >= 400) return 'warn'; return 'info'; }, })); ``` ### Alerting Thresholds | Metric | Warning | Critical | |--------|---------|----------| | Failed logins per IP (15 min) | > 5 | > 10 | | Failed logins per account (1 hour) | > 3 | > 5 | | 403 responses per IP (5 min) | > 10 | > 50 | | 500 errors (5 min) | > 5 | > 20 | | Request rate per IP (1 min) | > 100 | > 500 | --- ## Quick Reference: Security Checklist ### Authentication - [ ] bcrypt with cost >= 12 for password hashing - [ ] JWT with RS256, short expiry (15-30 min) - [ ] Refresh token rotation with family detection - [ ] Session regeneration on login - [ ] Secure cookie flags (httpOnly, secure, sameSite) ### Input Validation - [ ] Schema validation on all inputs (Zod) - [ ] Parameterized queries (never string concat) - [ ] File path sanitization - [ ] Content-Type validation ### Headers - [ ] Strict-Transport-Security - [ ] Content-Security-Policy - [ ] X-Content-Type-Options: nosniff - [ ] X-Frame-Options: DENY - [ ] CORS with specific origins ### Logging - [ ] Redact sensitive fields - [ ] Log security events - [ ] Include request IDs - [ ] Alert on anomalies ### Dependencies - [ ] npm audit in CI - [ ] Automated dependency updates - [ ] Lock file committed