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.

typescript
// 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:

typescript
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.

typescript
// 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 commands

Modern 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.

typescript
// 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:

typescript
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml);

File Upload Validation

File uploads are a high-risk input vector:

typescript
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

typescript
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 });
}
Try it yourself — TYPESCRIPT

Docker, AWS, Vercel, Netlify, GitHub, GitHub Actions are trademarks of Docker, Inc., Amazon.com, Inc., Vercel, Inc., Netlify, Inc., Microsoft Corporation. DevForge Academy is not affiliated with, endorsed by, or sponsored by these companies. Referenced for educational purposes only. See full disclaimers