The Cardinal Rule AI Breaks
Never trust user input.
AI generates code that directly uses request data:
// AI-generated (VULNERABLE)
const user = await db.users.create({
email: req.body.email, // Could be anything
name: req.body.name, // Could be malicious
age: req.body.age, // Could be a string, negative, etc.
})Without validation, attackers can:
- Inject malicious code
- Bypass business rules
- Corrupt your database
- Crash your application
Validation vs. Sanitization
Validation: Checking if input meets criteria
// Validation: Is this a valid email?
if (!email.includes('@')) throw new Error('Invalid email')Sanitization: Cleaning input to remove dangerous content
// Sanitization: Remove HTML tags
const clean = input.replace(/<[^>]*>/g, '')You need both.
Schema Validation with Zod
Zod is the standard for TypeScript validation:
npm install zodBasic Usage
import { z } from 'zod'const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
})
// In your API route
export async function POST(req: Request) {
const body = await req.json()
const result = UserSchema.safeParse(body)
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.issues },
{ status: 400 }
)
}
// result.data is typed and validated
const user = await db.users.create(result.data)
return Response.json(user)
}
Common Validations
// Strings
z.string()
z.string().email()
z.string().url()
z.string().uuid()
z.string().min(1).max(255)
z.string().regex(/^[a-zA-Z0-9_]+$/)// Numbers
z.number()
z.number().int()
z.number().positive()
z.number().min(0).max(100)
// Booleans
z.boolean()
// Dates
z.date()
z.string().datetime()
// Enums
z.enum(['admin', 'user', 'guest'])
// Arrays
z.array(z.string())
z.array(z.string()).min(1).max(10)
// Objects
z.object({
id: z.string().uuid(),
tags: z.array(z.string()),
})
// Optional and nullable
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string
null
undefined// Default values
z.string().default('guest')
z.number().default(0)
// Transform
z.string().transform(s => s.toLowerCase())
z.string().transform(s => s.trim())
API Route Validation Pattern
// lib/validate.ts
import { z, ZodSchema } from 'zod'export async function validateRequest(
request: Request,
schema: ZodSchema
): Promise<{ data: T } | { error: Response }> {
try {
const body = await request.json()
const data = schema.parse(body)
return { data }
} catch (error) {
if (error instanceof z.ZodError) {
return {
error: Response.json(
{ error: 'Validation failed', issues: error.issues },
{ status: 400 }
),
}
}
return {
error: Response.json(
{ error: 'Invalid request body' },
{ status: 400 }
),
}
}
}
// Usage in API route
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1).max(10000),
published: z.boolean().default(false),
})
export async function POST(request: Request) {
const result = await validateRequest(request, CreatePostSchema)
if ('error' in result) {
return result.error
}
const post = await db.posts.create({
data: result.data,
})
return Response.json(post)
}
Query Parameter Validation
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['asc', 'desc']).default('desc'),
search: z.string().max(100).optional(),
})export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const result = QuerySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
sort: searchParams.get('sort'),
search: searchParams.get('search'),
})
if (!result.success) {
return Response.json({ error: 'Invalid query parameters' }, { status: 400 })
}
const { page, limit, sort, search } = result.data
// Use validated and typed parameters
}
Sanitization Strategies
HTML Sanitization
import DOMPurify from 'isomorphic-dompurify'// Allow safe HTML (for rich text)
const cleanHtml = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
})
// Strip all HTML
const plainText = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
})
SQL-Safe Values
// DON'T sanitize - use parameterized queries
const query = SELECT * FROM users WHERE id = ${sanitize(id)} // WRONG// DO use parameters
const query = 'SELECT * FROM users WHERE id = $1'
await db.query(query, [id]) // RIGHT
Filename Sanitization
function sanitizeFilename(filename: string): string {
return filename
.replace(/[^a-zA-Z0-9.-]/g, '_') // Replace unsafe chars
.replace(/\.{2,}/g, '.') // Remove path traversal
.slice(0, 255) // Limit length
}// Usage
const safeFilename = sanitizeFilename(userFilename)
const path = uploads/${safeFilename}
Common Validation Patterns
Email Addresses
const emailSchema = z.string()
.email('Invalid email address')
.max(255)
.transform(email => email.toLowerCase().trim())Passwords
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password is too long')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number')URLs
const urlSchema = z.string()
.url('Invalid URL')
.refine(
url => url.startsWith('https://'),
'URL must use HTTPS'
)Phone Numbers
const phoneSchema = z.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')Monetary Values
const priceSchema = z.number()
.positive('Price must be positive')
.multipleOf(0.01, 'Invalid price format')
.max(999999.99, 'Price too high')Error Handling
Client-Friendly Errors
function formatZodErrors(error: z.ZodError) {
return error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
}// Response
{
"error": "Validation failed",
"issues": [
{ "field": "email", "message": "Invalid email address" },
{ "field": "age", "message": "Number must be greater than 0" }
]
}
Don't Leak Internal Details
// WRONG - Exposes internal structure
return Response.json({ error: error.message }, { status: 500 })// RIGHT - Generic message
return Response.json({ error: 'Invalid request' }, { status: 400 })
Validation Checklist
EVERY API ENDPOINT:
===================
[ ] Request body validated with schema
[ ] Query parameters validated
[ ] URL parameters validated
[ ] File uploads validated (type, size)VALIDATION RULES:
=================
[ ] Required fields marked required
[ ] String lengths limited
[ ] Numbers have min/max bounds
[ ] Enums for fixed value sets
[ ] Regex for format validation
SANITIZATION:
=============
[ ] HTML sanitized when needed
[ ] Filenames cleaned
[ ] Paths checked for traversal
[ ] URLs validated for SSRF
ERROR HANDLING:
===============
[ ] Validation errors return 400
[ ] Errors don't expose internals
[ ] Field-level error messages
The Bottom Line
AI generates code that trusts everything. You must add validation to every input: request bodies, query parameters, URL parameters, and file uploads.
Validate first, process after. Never the other way around.