Input validation as defense

You have been validating request bodies with Zod since the REST APIs phase. The hardening upgrade is extending the same rule to everything coming in from the outside. Query strings, path params, headers, file metadata. If it crossed the wire, validate it before your code trusts it.

after/controllers/book.controller.ts
typescript
const BooksQuerySchema = z.object({
  cursor: z.coerce.number().int().nonnegative().optional().default(0),
  limit: z.coerce.number().int().positive().max(100).optional().default(20),
});

export async function getBooksHandler(req: Request, res: Response) {
  const parsed = BooksQuerySchema.safeParse(req.query);
  if (!parsed.success) {
    res.status(400).json({ error: 'Invalid query params', details: parsed.error.format() });
    return;
  }
  const { cursor, limit } = parsed.data;
  // ... cursor pagination
}

Validate query parameters with the same Zod schemas you use for bodies.

One small detail worth calling out: z.coerce. Query strings are always strings. z.coerce.number() tells Zod to cast the string to a number before validating. Without coerce, your number field fails on 20 because 20 is a string at this point. With coerce, the schema does the right thing and your code downstream never has to parseInt.

SQL injection is the classic reason to validate query params, but it is not the only one. You also want to catch oversized limits (a user asks for limit=1000000 and kills your database), negative cursors (which could surface data from before the intended range), and type confusion from weird clients. Zod catches all of them in one schema.

Validation checklist: Harden the book API

Loading practice…

Checkpoint: API hardening checkpoint

Loading practice…