|$ curl https://forge-ai.dev/api/markdown?path=docs/security/input-validation
$cat docs/security-—-input-validation-&-sanitization.md
updated Recently·18 min read·published

Security — Input Validation & Sanitization

SecurityIntermediate🎯Free Tools
Introduction

Input validation is the first line of defense against injection attacks, data corruption, and business logic abuse. Every piece of data that enters your application from an external source — request bodies, query parameters, headers, file uploads, WebSocket messages — must be validated before use.

Validation answers two questions: "Is this the right type of data?" (type checking) and "Is this an acceptable value?" (business rules). Client-side validation improves UX but provides zero security — always validate on the server.

Allowlists vs Blocklists

Allowlists (whitelists) define what is permitted and reject everything else. Blocklists (blacklists) define what is forbidden and allow everything else. Allowlists are always safer because they handle unknown attack vectors automatically.

allowlist-vs-blocklist.ts
TypeScript
1// Allowlist approach — define what's allowed
2// GOOD: only accepts known-good values
3function validateUserRole(role: string): boolean {
4 const allowed = ['admin', 'editor', 'viewer', 'moderator'];
5 return allowed.includes(role);
6}
7
8// GOOD: regex allowlist for usernames
9function isValidUsername(username: string): boolean {
10 // Only alphanumeric, hyphens, underscores, 3-30 chars
11 return /^[a-zA-Z0-9_-]{3,30}$/.test(username);
12}
13
14// GOOD: type-safe enum validation
15type Status = 'active' | 'inactive' | 'pending';
16function validateStatus(status: string): status is Status {
17 return ['active', 'inactive', 'pending'].includes(status);
18}
19
20// ────────────────────────────────────────────
21
22// Blocklist approach — define what to reject
23// BAD: tries to catch known-bad patterns (misses new ones)
24function isSafeInput(input: string): boolean {
25 const blocked = ['<script>', 'javascript:', 'onerror=', 'onload='];
26 return !blocked.some(pattern =>
27 input.toLowerCase().includes(pattern)
28 );
29}
30
31// Why blocklists fail:
32// 1. New attack vectors appear constantly
33// 2. Case variations: <Script>, <SCRIPT>, <script>
34// 3. Encoding tricks: &#60;script&#62;, %3Cscript%3E
35// 4. Nested bypass: <scr<script>ipt>
36// 5. null bytes: <scr\0ipt>
37// An allowlist for "alphanumeric only" blocks ALL of these automatically
38
39// Allowlist for file uploads
40const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
41const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'];
42const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
43
44function validateFileUpload(file: File): { valid: boolean; error?: string } {
45 if (!ALLOWED_MIMES.includes(file.type)) {
46 return { valid: false, error: 'Invalid file type' };
47 }
48 if (file.size > MAX_FILE_SIZE) {
49 return { valid: false, error: 'File too large' };
50 }
51 return { valid: true };
52}

danger

Blocklists are fundamentally insufficient for security. They rely on knowing all attack patterns, which is impossible. An attacker will always find a variation you didn't block. Use allowlists that define exactly what is accepted and reject everything else.
Schema Validation with Zod

Zod is a TypeScript-first schema validation library that provides type inference, detailed error messages, and composable schemas. It is the most popular choice for validating API inputs in TypeScript projects.

zod-validation.ts
TypeScript
1// Zod schema validation — comprehensive example
2import { z } from 'zod';
3
4// User registration schema
5const RegisterSchema = z.object({
6 name: z
7 .string()
8 .min(1, 'Name is required')
9 .max(100, 'Name too long')
10 .regex(/^[a-zA-Z\s'-]+$/, 'Name contains invalid characters'),
11
12 email: z
13 .string()
14 .email('Invalid email format')
15 .max(255, 'Email too long')
16 .toLowerCase(),
17
18 password: z
19 .string()
20 .min(12, 'Password must be at least 12 characters')
21 .max(128, 'Password too long')
22 .regex(/[A-Z]/, 'Must contain uppercase letter')
23 .regex(/[a-z]/, 'Must contain lowercase letter')
24 .regex(/[0-9]/, 'Must contain number')
25 .regex(/[^A-Za-z0-9]/, 'Must contain special character'),
26
27 age: z
28 .number()
29 .int('Age must be a whole number')
30 .min(13, 'Must be at least 13')
31 .max(150, 'Invalid age')
32 .optional(),
33
34 role: z.enum(['user', 'editor']).default('user'),
35
36 website: z
37 .string()
38 .url('Invalid URL')
39 .max(200)
40 .regex(/^https:\/\//, 'Must use HTTPS')
41 .optional()
42 .or(z.literal('')),
43});
44
45// Express route with Zod validation
46app.post('/api/register', async (req, res) => {
47 const result = RegisterSchema.safeParse(req.body);
48
49 if (!result.success) {
50 return res.status(400).json({
51 error: 'Validation failed',
52 details: result.error.issues.map(issue => ({
53 field: issue.path.join('.'),
54 message: issue.message,
55 })),
56 });
57 }
58
59 // result.data is fully typed and validated
60 const { name, email, password, age, role, website } = result.data;
61
62 // Now safe to use — all values are validated
63 await createUser({ name, email, password: await hash(password), age, role, website });
64 res.status(201).json({ message: 'Account created' });
65});
66
67// Nested object validation
68const UpdateProfileSchema = z.object({
69 name: z.string().min(1).max(100),
70 address: z.object({
71 street: z.string().min(1).max(200),
72 city: z.string().min(1).max(100),
73 zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
74 country: z.enum(['US', 'CA', 'UK', 'DE', 'FR']),
75 }).optional(),
76 preferences: z.object({
77 newsletter: z.boolean().default(false),
78 theme: z.enum(['light', 'dark', 'system']).default('system'),
79 }).optional(),
80});
81
82// Array validation
83const BatchUpdateSchema = z.object({
84 items: z.array(z.object({
85 id: z.string().uuid(),
86 action: z.enum(['update', 'delete']),
87 data: z.record(z.string(), z.unknown()).optional(),
88 })).min(1).max(100), // Limit array size
89});

info

Always validate at the API boundary (route handler or middleware), not deep in business logic. Zod schemas serve as both validation and documentation — they define exactly what your API accepts. Use .safeParse() to avoid throwing on invalid input.
Schema Validation with Joi

Joi is a widely-used validation library with a chainable API. It is common in Express projects and provides powerful validation, sanitization, and error reporting.

joi-validation.ts
TypeScript
1// Joi schema validation — Express middleware
2import Joi from 'joi';
3import express from 'express';
4
5// Validation middleware factory
6function validate(schema: Joi.ObjectSchema) {
7 return (req, res, next) => {
8 const { error, value } = schema.validate(req.body, {
9 abortEarly: false, // Return all errors, not just the first
10 stripUnknown: true, // Remove fields not in the schema
11 });
12
13 if (error) {
14 return res.status(400).json({
15 error: 'Validation failed',
16 details: error.details.map(d => ({
17 field: d.path.join('.'),
18 message: d.message,
19 })),
20 });
21 }
22
23 req.body = value; // Use sanitized/validated values
24 next();
25 };
26}
27
28// User update schema
29const updateUserSchema = Joi.object({
30 name: Joi.string()
31 .min(1)
32 .max(100)
33 .pattern(/^[a-zA-Z\s'-]+$/)
34 .messages({
35 'string.pattern.base': 'Name contains invalid characters',
36 }),
37
38 email: Joi.string()
39 .email({ tlds: { allow: true } })
40 .max(255)
41 .lowercase(),
42
43 bio: Joi.string()
44 .max(5000)
45 .allow('')
46 .optional(),
47
48 age: Joi.number()
49 .integer()
50 .min(13)
51 .max(150)
52 .optional(),
53
54 role: Joi.string()
55 .valid('user', 'editor', 'admin')
56 .default('user'),
57});
58
59// Password schema with strong rules
60const changePasswordSchema = Joi.object({
61 currentPassword: Joi.string().min(1).required(),
62 newPassword: Joi.string()
63 .min(12)
64 .max(128)
65 .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d])/)
66 .required()
67 .invalid(Joi.ref('currentPassword'))
68 .messages({
69 'any.invalid': 'New password must differ from current',
70 'string.pattern.base': 'Password must include uppercase, lowercase, number, and special character',
71 }),
72 confirmPassword: Joi.any()
73 .valid(Joi.ref('newPassword'))
74 .required()
75 .messages({ 'any.only': 'Passwords do not match' }),
76});
77
78// Apply validation middleware
79app.put('/api/profile', validate(updateUserSchema), (req, res) => {
80 // req.body is validated and sanitized
81 res.json({ success: true });
82});
83
84app.post('/api/change-password', validate(changePasswordSchema), (req, res) => {
85 // Passwords validated, stripUnknown removed confirmPassword
86 res.json({ success: true });
87});
Sanitization & Escaping

Sanitization cleans input by removing or modifying dangerous characters. Escaping transforms special characters into safe representations. They serve different purposes: sanitization modifies data before storage, escaping protects data during rendering.

sanitization-escaping.ts
TypeScript
1// HTML escaping — prevent XSS in output
2import escapeHtml from 'escape-html';
3
4// Characters that are dangerous in HTML
5// < → &lt; > → &gt; & → &amp; " → &quot; ' → &#x27;
6
7const userInput = '<script>alert("XSS")</script>';
8const safe = escapeHtml(userInput);
9// Result: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;
10
11// ────────────────────────────────────────────
12
13// SQL escaping — NEVER use string interpolation
14// BAD — SQL injection
15const query = `SELECT * FROM users WHERE name = '${name}'`;
16
17// GOOD — parameterized queries (the DB driver handles escaping)
18await db.query('SELECT * FROM users WHERE name = $1', [name]);
19
20// ────────────────────────────────────────────
21
22// URL encoding
23const unsafe = 'https://example.com/search?q=hello world&lang=en';
24const safe = encodeURIComponent(unsafe);
25// Result: https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%20world%26lang%3Den
26
27// ────────────────────────────────────────────
28
29// JavaScript string escaping
30const userInput = '</script><script>alert(1)</script>';
31const safe = JSON.stringify(userInput);
32// Result: "<\/script><script>alert(1)<\/script>"
33// JSON.stringify escapes < / properly for script contexts
34
35// ────────────────────────────────────────────
36
37// CSS escaping
38function escapeCssValue(value: string): string {
39 // Only allow alphanumeric, hyphens, and underscores
40 return value.replace(/[^a-zA-Z0-9_-]/g, '');
41}
42
43// ────────────────────────────────────────────
44
45// Shell command escaping
46import { execFile } from 'child_process';
47
48// BAD — shell injection
49exec(`ping -c 4 ${userInput}`); // userInput: ; rm -rf /
50
51// GOOD — use execFile with array arguments (no shell interpretation)
52execFile('ping', ['-c', '4', userInput], (err, stdout) => {
53 // userInput is passed as a single argument, not parsed by shell
54});
55
56// BEST — avoid shell commands entirely, use Node.js APIs

danger

Escaping and sanitization are context-dependent. HTML escaping does not protect against SQL injection. URL encoding does not protect against XSS. Always match the defense to the output context, and use parameterized queries for all database access.
Type Coercion Attacks

JavaScript's loose type coercion can be exploited to bypass validation. An attacker can send a string where a number is expected, an array where a string is expected, or an object where a primitive is expected.

type-coercion.ts
TypeScript
1// Type coercion vulnerabilities
2// JavaScript: "1" == 1 is true, [] == false is true
3
4// BAD — loose equality allows bypass
5function isValidAge(value: any): boolean {
6 if (value == null) return false; // null == undefined passes!
7 if (value == false) return false; // 0, "", null, undefined all pass!
8 return value >= 13 && value <= 150;
9}
10
11isValidAge(null); // false (correct)
12isValidAge(undefined); // false (correct)
13isValidAge(''); // false (correct, but by accident)
14isValidAge([]); // false (correct, but by accident)
15
16// BAD — array bypass
17function validateEmail(email: string): boolean {
18 return typeof email === 'string' && email.includes('@');
19}
20// Attacker sends: email[]=admin@example.com
21// req.body.email is an array, not a string
22// typeof [] === 'object', so it fails... but typeof ['@'] === 'object'
23
24// GOOD — explicit type checking with Zod
25const Schema = z.object({
26 email: z.string().email(), // Rejects arrays, objects, numbers
27 age: z.number().int().min(13).max(150), // Rejects strings
28 name: z.string().min(1).max(100),
29});
30
31// GOOD — check constructor
32function safeNumber(value: unknown): number | null {
33 if (typeof value === 'number' && Number.isFinite(value)) {
34 return value;
35 }
36 if (typeof value === 'string') {
37 const parsed = Number(value);
38 if (Number.isFinite(parsed)) return parsed;
39 }
40 return null;
41}
42
43// GOOD — reject unexpected types early
44app.post('/api/data', (req, res) => {
45 if (typeof req.body.amount !== 'number') {
46 return res.status(400).json({ error: 'Amount must be a number' });
47 }
48 if (typeof req.body.name !== 'string') {
49 return res.status(400).json({ error: 'Name must be a string' });
50 }
51 // Now safe to use
52});
53
54// Array.prototype pollution protection
55// Express parses: ?ids[]=1&ids[]=2 as { ids: ['1', '2'] }
56// Attack: ?__proto__[isAdmin]=true → pollutes Object.prototype
57// Defense: use Object.create(null) or check for prototype keys
Regex Validation & ReDoS

Regular expressions are powerful for validation but can introduce vulnerabilities. ReDoS (Regular Expression Denial of Service) occurs when a crafted input causes catastrophic backtracking, freezing the server.

regex-validation.ts
TypeScript
1// Safe regex patterns for common validations
2
3// Email — simple but sufficient for most cases
4const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
5
6// URL — HTTPS only
7const httpsUrlRegex = /^https:\/\/[^\s/$.?#].[^\s]*$/i;
8
9// Phone number (US format)
10const phoneRegex = /^\+?1?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/;
11
12// UUID
13const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
15// Date (YYYY-MM-DD)
16const dateRegex = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
17
18// ────────────────────────────────────────────
19
20// ReDoS vulnerability — catastrophic backtracking
21// BAD — nested quantifiers cause exponential time
22const badEmailRegex = /^([a-zA-Z0-9]+)+@[a-zA-Z0-9]+\.[a-zA-Z]+$/;
23// Input: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
24// This causes 2^n backtracking steps, freezing the server
25
26// GOOD — use atomic groups or non-backtracking regex
27// Node.js 16+: use the 'd' flag and limit repetition
28const safeRegex = /^[a-zA-Z0-9]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/;
29
30// Test your regex for ReDoS vulnerability
31import { execSync } from 'child_process';
32
33function testReDoS(pattern: string, testInput: string, timeoutMs = 1000): boolean {
34 try {
35 execSync(
36 `node -e "new RegExp('${pattern}').test('${testInput}')"`,
37 { timeout: timeoutMs }
38 );
39 return false; // No ReDoS
40 } catch {
41 return true; // ReDoS detected (timeout)
42 }
43}
44
45// Use libraries with built-in ReDoS protection
46// email-regex-safe, re2 (Google's RE2), safe-regex
47
48// Length limits — always enforce BEFORE regex
49// BAD: regex on unbounded input
50const result = input.match(/^[a-zA-Z0-9]+$/); // Attacker sends 1GB of data
51
52// GOOD: length check first
53if (input.length > 100) return false;
54const result = input.match(/^[a-zA-Z0-9]+$/);

warning

ReDoS is a real production risk. A single vulnerable regex on a public endpoint can be exploited to denial-of-service your entire application. Always enforce input length limits before running regex patterns, and test regexes with adversarial inputs.
Validation at Boundaries

Validate at every trust boundary — where data crosses from untrusted to trusted space. This includes API endpoints, database queries, file system operations, and third-party API responses.

boundary-validation.ts
TypeScript
1// Validation at every boundary
2
3// 1. API input boundary (most critical)
4app.post('/api/orders', validate(orderSchema), async (req, res) => {
5 const { items, shippingAddress } = req.body;
6
7 // 2. Database query boundary — validate IDs
8 for (const item of items) {
9 if (!isValidUUID(item.productId)) {
10 return res.status(400).json({ error: 'Invalid product ID' });
11 }
12 const product = await db.products.findById(item.productId);
13 if (!product) {
14 return res.status(404).json({ error: 'Product not found' });
15 }
16 }
17
18 // 3. Third-party API boundary — validate responses
19 const paymentResult = await stripe.charges.create({
20 amount: calculateTotal(items),
21 currency: 'usd',
22 });
23
24 // Validate the response from Stripe (never trust external APIs)
25 if (typeof paymentResult.id !== 'string' || !paymentResult.paid) {
26 throw new Error('Payment failed');
27 }
28
29 // 4. File system boundary — validate file paths
30 const filePath = path.join(UPLOAD_DIR, userProvidedFilename);
31 const resolved = path.resolve(filePath);
32 if (!resolved.startsWith(UPLOAD_DIR)) {
33 return res.status(400).json({ error: 'Invalid file path' }); // Path traversal
34 }
35
36 // 5. WebSocket message boundary
37 ws.on('message', (data) => {
38 const result = WebSocketMessageSchema.safeParse(
39 JSON.parse(data.toString())
40 );
41 if (!result.success) {
42 ws.close(1008, 'Invalid message');
43 return;
44 }
45 handleMessage(result.data);
46 });
47});
48
49// 6. Query parameter boundary
50app.get('/api/search', (req, res) => {
51 const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
52 const limit = Math.min(100, Math.max(1, Math.floor(Number(req.query.limit) || 20)));
53 const q = String(req.query.q || '').slice(0, 200);
54 // Now safe to use page, limit, q
55});
56
57// 7. Environment variable boundary
58const port = parseInt(process.env.PORT || '3000', 10);
59if (isNaN(port) || port < 1 || port > 65535) {
60 throw new Error('Invalid PORT environment variable');
61}
Secure File Upload Validation

File uploads are a high-risk input vector. Attackers can upload executable scripts, oversized files to cause DoS, or files with malicious content disguised by extension. Validate on multiple levels.

secure-file-upload.ts
TypeScript
1// Secure file upload validation
2import multer from 'multer';
3import path from 'path';
4import crypto from 'crypto';
5
6const ALLOWED_TYPES = {
7 'image/jpeg': ['.jpg', '.jpeg'],
8 'image/png': ['.png'],
9 'image/webp': ['.webp'],
10 'application/pdf': ['.pdf'],
11};
12
13const MAX_SIZE = 5 * 1024 * 1024; // 5MB
14
15const upload = multer({
16 storage: multer.diskStorage({
17 destination: '/tmp/uploads',
18 filename: (req, file, cb) => {
19 // Random filename — never use the original name
20 const ext = path.extname(file.originalname).toLowerCase();
21 const randomName = crypto.randomBytes(32).toString('hex');
22 cb(null, `${randomName}${ext}`);
23 },
24 }),
25 limits: {
26 fileSize: MAX_SIZE,
27 files: 5,
28 },
29 fileFilter: (req, file, cb) => {
30 // 1. Check MIME type
31 const allowedExtensions = ALLOWED_TYPES[file.mimetype];
32 if (!allowedExtensions) {
33 return cb(new Error('Invalid file type'));
34 }
35
36 // 2. Check extension
37 const ext = path.extname(file.originalname).toLowerCase();
38 if (!allowedExtensions.includes(ext)) {
39 return cb(new Error('Invalid file extension'));
40 }
41
42 // 3. Check for double extensions (malware trick)
43 const basename = path.basename(file.originalname, ext);
44 if (basename.includes('.')) {
45 return cb(new Error('Invalid filename'));
46 }
47
48 cb(null, true);
49 },
50});
51
52// Apply to route
53app.post('/api/upload', upload.single('file'), (req, res) => {
54 const file = req.file;
55
56 // 4. Verify file content matches declared type
57 // (MIME type can be spoofed — check magic bytes)
58 // Use file-type or FileType library
59
60 // 5. Scan for malware (integrate with ClamAV or similar)
61 // await scanFile(file.path);
62
63 res.json({
64 filename: file.filename,
65 size: file.size,
66 type: file.mimetype,
67 });
68});

danger

Never trust the filename or MIME type from the client — both can be spoofed. Generate a random filename on the server, validate the extension against an allowlist, and check the file's magic bytes to verify its actual content type.
Best Practices
  • Always validate on the server — client-side validation is UX, not security.
  • Use allowlists over blocklists — define what is accepted, reject everything else.
  • Validate type, length, format, and range — not just presence.
  • Enforce maximum length limits on all string inputs before regex or DB queries.
  • Use schema libraries (Zod, Joi) instead of manual validation — they handle edge cases.
  • Strip unknown fields with stripUnknown (Joi) or .pick() (Zod) to prevent mass assignment.
  • Validate at every trust boundary: API, DB, file system, third-party APIs, WebSocket.
  • Sanitize for context — HTML escaping does not protect SQL, and vice versa.
  • Test for ReDoS vulnerabilities and enforce input length before regex.
  • Log validation failures for security monitoring — they indicate attack attempts.

best practice

Input validation is not optional — it is a security requirement. Treat every external input as untrusted until proven otherwise. Validation schemas should be living documents that evolve as your API grows. Review them regularly and add new constraints as attack patterns emerge.
$Blueprint — Engineering Documentation·Section ID: SEC-INPUT·Revision: 1.0