Security Fundamentals
Input Validation & Injection Prevention
Master the techniques that prevent the most common web vulnerabilities: injection attacks and malicious input.
The Golden Rule
Never trust client-side input.
Client-side validation (React form validation, browser required fields) improves user experience. It is not security. Any user with DevTools or curl can bypass client-side validation entirely.
Server-side validation is mandatory. It cannot be bypassed.
Allowlisting vs Denylisting
Two validation philosophies:
Denylisting — specify what is NOT allowed (block known bad patterns).
Allowlisting — specify what IS allowed (reject anything that doesn't match).
Allowlisting is always safer. Attackers are creative — they find patterns you forgot to block. Allowlisting means only known-good input passes.
// Denylisting (weak) — attacker might find something you missed
const isSafe = !input.includes('<script>') && !input.includes('DROP TABLE');
// Allowlisting (strong) — only letters, numbers, hyphens
const isValidSlug = /^[a-z0-9-]+$/.test(input);Zod for Schema Validation
Zod is the TypeScript-first validation library of choice for Next.js applications:
import { z } from 'zod';
const RegisterSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_-]+$/, 'Username: letters, numbers, underscores, hyphens only'),
bio: z.string().max(500).optional(),
});
export async function POST(req: Request) {
const body = await req.json();
const result = RegisterSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
// result.data is now fully typed and validated
const { email, password, username } = result.data;
}SQL Injection
SQL injection is one of the oldest and most destructive vulnerabilities. It happens when user input is concatenated directly into a SQL query.
// VULNERABLE — never do this
const rows = await db.query(
`SELECT * FROM users WHERE username = '${username}'`
);
// If username = "'; DROP TABLE users; --"
// The query becomes: SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
// SECURE — parameterized query
const rows = await db.query(
'SELECT * FROM users WHERE username = $1',
[username]
);
// The database treats $1 as data, never as SQL commandsModern ORMs (Prisma, Drizzle, Sequelize) handle parameterization automatically — but be careful with raw query escape hatches.
XSS: Cross-Site Scripting
XSS attacks inject malicious scripts into pages viewed by other users.
// VULNERABLE — unsanitized HTML insertion
element.innerHTML = userContent;
// SECURE — React's JSX is safe (auto-escapes HTML)
return <div>{userContent}</div>;
// DANGEROUS in React — only use with sanitized content
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;If you must render HTML from user input, sanitize it first with a library like DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml);File Upload Validation
File uploads are a high-risk input vector:
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function uploadHandler(file: File) {
// Check MIME type (not just extension — easy to spoof)
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error('Invalid file type');
}
// Check file size
if (file.size > MAX_SIZE) {
throw new Error('File too large');
}
// Store outside the web root (not publicly accessible by default)
// Use a CDN or object storage (S3), not the server's filesystem
}Key Takeaways
- Server-side validation is mandatory — client-side validation is UX only
- Allowlisting (specify what IS allowed) is safer than denylisting (block known bad patterns)
- Use Zod for typed, declarative schema validation in TypeScript/Next.js applications
- Always use parameterized queries or ORMs — never concatenate user input into SQL
- React's JSX auto-escapes HTML — avoid dangerouslySetInnerHTML unless the content is already sanitized
Example
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(50000),
slug: z.string().regex(/^[a-z0-9-]+$/),
tags: z.array(z.string().max(50)).max(10).optional(),
});
export async function POST(req: Request) {
const body = await req.json();
const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
const { title, content, slug, tags } = result.data;
// Use parameterized queries — never interpolate
const post = await db.query(
'INSERT INTO posts (title, content, slug) VALUES ($1, $2, $3) RETURNING *',
[title, content, slug]
);
return Response.json(post.rows[0], { status: 201 });
}