codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
EDU›Web security foundations — JWT · OAuth · OWASP›Step 4

Step 4

Input validation + length caps

0 views

Input validation + length caps

Every value from the client is suspect. Revalidate on the server with zod or Valibot.

1. Why re-validate?

  • Browser checks are bypassable (DevTools, curl)
  • Frontend version drift can skip checks
  • Malicious clients don't use your UI

Frontend validation is UX; server validation is the boundary.

2. zod minimal

pnpm add zod
import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(10000),
  tags: z.array(z.string().max(30)).max(10).optional(),
  published: z.boolean().default(false),
});

const parsed = CreatePostSchema.safeParse(await req.json());
if (!parsed.success) return NextResponse.json(
  { error: "invalid", issues: parsed.error.flatten() }, { status: 400 }
);

3. Why length caps

  • DoS — megabyte strings blow memory
  • Storage cost — single user writes GBs
  • Index perf — long text indexes get slow

Cap every string.

4. Common schemas

export const EmailSchema = z.string().email().max(120).toLowerCase();
export const SlugSchema = z.string().regex(/^[a-z0-9-]+$/).min(1).max(80);

5. Nested / unions

const NotificationSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("comment"), postId: z.number().int().positive(), commentId: z.number().int().positive() }),
  z.object({ type: z.literal("like"), postId: z.number().int().positive() }),
  z.object({ type: z.literal("follow"), userId: z.string().uuid() }),
]);

6. transform + pipe

const TrimmedSchema = z.string().transform(s => s.trim()).pipe(z.string().min(1));

const PhoneSchema = z.string()
  .transform(s => s.replace(/[\s-]/g, ""))
  .pipe(z.string().regex(/^010\d{8}$/));

7. Error shape

return NextResponse.json({
  error: "validation_failed",
  issues: parsed.error.issues.map(i => ({
    path: i.path.join("."), message: i.message, code: i.code,
  })),
}, { status: 400 });

8. Query strings

const QuerySchema = z.object({
  page: z.coerce.number().int().min(1).max(10000).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  q: z.string().max(100).optional(),
});
const params = QuerySchema.parse(Object.fromEntries(url.searchParams));

9. File upload

const MAX_SIZE = 10 * 1024 * 1024;
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);

if (file.size > MAX_SIZE) return NextResponse.json({ error: "too_large" }, { status: 413 });
if (!ALLOWED_MIME.has(file.type)) return NextResponse.json({ error: "mime" }, { status: 415 });
// Also verify magic bytes — `file.type` can lie

10. Gotchas

  • Missing max on string fields
  • No coerce when parsing query
  • Error messages leak internal info
  • Using raw input instead of parsed output

Closing

A single z.string().min(1).max(N) helps against XSS, DoS, and memory attacks at once. Build schema files from day one.

Next

  • 05-rate-limit-cors

← Step 3

OAuth + state · PKCE

Step 5 →

Rate limit + CORS + security headers