Input validation in API routes: zod schemas for Next.js
Input validation in API routes means checking every body, query string and URL param on the server before your code uses it. In Next.js, run each request through a zod schema, reject unknown keys, cap lengths, and write only the fields the schema returns. Never pass req.json() straight to your database, because the client decides what is in it.
QUICK CHECKLIST
- Parse every body, query and param with a zod schema
- Use z.strictObject so unknown keys return a 400
- Cap string lengths, array sizes and body bytes
- Write result.data, never req.json() or a spread
- Take user id and role from the session, not the body
- Test with an extra role field and an oversized body
Why input validation in API routes matters
A route handler is a public URL. Your form sends tidy JSON, but anyone can send the same request with curl (a command-line HTTP client) and put whatever they like in it.
Input validation checks that data fits your rules before the app uses it. OWASP's cheat sheet asks for allowed sets for fixed choices, a type plus a range for numbers, length limits for strings and item counts for arrays. A zod schema (zod is a TypeScript validation library) writes those rules once.
Validation is one layer. A valid user id doesn't mean the caller may touch that user, so you still need ownership checks and parameterized queries (see SQL injection through an ORM).
How mass assignment and oversized input get through
Mass assignment means a request carries fields you never meant to accept, and your code saves them. OWASP notes that attackers can guess field names without seeing your source. Typical targets are role, isAdmin, plan, credits, ownerId and emailVerified.
Spreading the body is the shortest code that works, so AI tools reach for it. With Prisma (a Node.js database library) it looks like prisma.user.update({ data: { ...body } }), and with Supabase like supabase.from('profiles').update(body). Prisma rejects keys that aren't columns, but it accepts every real column.
Oversized or odd input causes other trouble:
- A 5 MB
biostring fills your database and slows every page that shows it. - A huge array makes your own loop burn CPU.
- A
priceoruserIdtrusted from the body (see Stripe price tampering). - An object where you expected a string can change what a query means (NoSQL injection).
Before and after: stop spreading req.json()
Here is how to validate a request body with zod in a Next.js route handler. The wrong version spreads the body. The fixed version names every field and writes only what the schema returns.
// app/api/profile/route.ts
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
export async function PATCH(req: Request) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const body = await req.json();
const user = await prisma.user.update({
where: { id: session.user.id },
data: { ...body }, // role, plan, credits: all writable
});
return Response.json(user); // returns every column
}// app/api/profile/route.ts
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { parseBody } from "@/lib/validate";
const UpdateProfile = z.strictObject({
name: z.string().trim().min(1).max(80),
bio: z.string().max(500).optional(),
theme: z.enum(["light", "dark"]).optional(),
});
export async function PATCH(req: Request) {
const session = await auth();
if (!session?.user) return new Response("Unauthorized", { status: 401 });
const result = await parseBody(req, UpdateProfile);
if (!result.ok) return result.response;
const user = await prisma.user.update({
where: { id: session.user.id },
data: result.data,
select: { id: true, name: true, bio: true, theme: true },
});
return Response.json(user);
}What changed:
z.strictObjectaccepts three named fields and returns an error for anything else, such asrole.- Each field has a type and a cap.
themeis an enum, so it must match one value from a fixed list. - The write uses
result.data, neverbody. The user id comes from the session, andselectlimits the response to four columns.
How to validate API route input with zod, step by step
Write the helper once. After that, every route is a schema plus three lines.
Write one schema per route
Name every field. Give strings a
.max(), numbers.int().min().max(), choicesz.enum([...]), and arrays a.max()on count and on each item. Usez.strictObjectfor anything that writes data. Never acceptuserId,role,plan,ownerIdorpricefrom the body. Read them from the session or compute them on the server.Add one shared check helper
Use
safeParse, becauseparsethrows and turns a bad request into a 500. Return issue paths and messages, not the raw input.// lib/validate.ts import { z } from "zod"; type Result<T> = { ok: true; data: T } | { ok: false; response: Response }; export function check<S extends z.ZodType>(schema: S, input: unknown): Result<z.output<S>> { const parsed = schema.safeParse(input); if (parsed.success) return { ok: true, data: parsed.data }; const errors = parsed.error.issues.map((i) => ({ path: i.path.map(String).join("."), message: i.message, })); return { ok: false, response: Response.json({ error: "Invalid input", errors }, { status: 400 }), }; }Cap the body size before you parse
OWASP says to apply size limits before parsing. Check
Content-Lengthfirst, then count bytes while reading, since that header can be missing or wrong.// lib/validate.ts (same file) const MAX_BYTES = 100_000; async function readLimited(req: Request): Promise<string | null> { if (Number(req.headers.get("content-length") ?? 0) > MAX_BYTES) return null; const reader = req.body?.getReader(); if (!reader) return ""; const decoder = new TextDecoder(); let size = 0; let text = ""; for (;;) { const { done, value } = await reader.read(); if (done) return text + decoder.decode(); size += value.byteLength; if (size > MAX_BYTES) { await reader.cancel(); return null; } text += decoder.decode(value, { stream: true }); } } export async function parseBody<S extends z.ZodType>(req: Request, schema: S) { const text = await readLimited(req); if (text === null) { return { ok: false as const, response: new Response("Body too large", { status: 413 }) }; } let json: unknown; try { json = JSON.parse(text); } catch { return { ok: false as const, response: new Response("Invalid JSON", { status: 400 }) }; } return check(schema, json); }Pick
MAX_BYTESto fit your largest real request. Your host and Next.js may set their own limits, so check their current docs and keep your own cap.Validate query strings and params too
Query values arrive as strings, so numbers need
z.coerce.number(). PassObject.fromEntries(req.nextUrl.searchParams)tocheck(). For dynamic segments, validateawait paramsthe same way, for example withz.object({ id: z.uuid() }).Repeat it inside every server action
A server action is a function marked
use serverthat a form calls. Next.js docs say anyone can reach an exported one with a direct POST request, so server action validation and auth checks belong inside each action. Check the session first, then runsafeParseon the form values. More in Next.js server actions security.
Coercion, length and size traps
z.coerce calls the JavaScript constructor on whatever arrives: Boolean(input), String(input) or Number(input). The table shows where that goes wrong.
| What it does | Safer move | |
|---|---|---|
| z.coerce.number() | Empty string becomes 0 | Add .min(1), or preprocess "" to undefined |
| z.coerce.boolean() | The string "false" becomes true | Use z.enum(["true","false"]) |
| z.coerce.string() | null becomes the string "null" | Use plain z.string() |
Coercion calls JavaScript constructors, so odd input can still pass.
Coerce only where the input really is a string, like query params and form data. In JSON bodies, send real numbers and booleans and validate them as such.
Two more traps:
- Zod counts string length in characters, not bytes, so a
.max(500)field can hold more than 500 bytes. Keep the body cap too. z.looseObject()(.passthrough()in older code) keeps unknown keys. Don't use it for anything you write.
Test it yourself
Copy the Cookie header from your browser's Network tab after you log in, then send requests the form would never send.
URL=http://localhost:3000/api/profile
COOKIE="YOUR_SESSION_COOKIE"
send() { curl -i -X PATCH "$URL" -H "Content-Type: application/json" -H "Cookie: $COOKIE" "$@"; }
# 1. Extra key. Expect 400.
send -d '{"name":"Test","role":"admin"}'
# 2. Wrong type. Expect 400.
send -d '{"name":["a","b"]}'
# 3. Huge body. Expect 413.
node -e "console.log(JSON.stringify({bio:'a'.repeat(300000)}))" > huge.json
send --data @huge.jsonBad results: a 200 on test 1, a 500 with a stack trace on test 2, or a changed role in your database. Good results: a 400 listing the offending paths on tests 1 and 2, a 413 on test 3, and an unchanged row.
In the browser, delete the maxlength attribute in devtools and submit 10,000 characters. You should get a validation error back.
Fix prompt you can paste
Give this to Cursor or Claude Code.
Audit input validation in every Next.js route handler (app/**/route.ts) and every "use server" action in this repo. Find: req.json(), request.formData(), searchParams.get() and params that reach a database call or external API without a zod schema. Also find z.object or .passthrough() schemas on routes that write data, .parse() calls that throw instead of returning 400, and spreads of request data into writes: ...body, data: body, .update(body), Object.fromEntries(formData). First list every finding as path:line. Then fix each one: 1. Create a zod schema (Zod 4 API: z.strictObject, z.email, z.uuid, z.enum) with .max() on strings and arrays, and .int().min().max() on numbers. Use z.coerce only for query strings and FormData, never z.coerce.boolean. 2. Add lib/validate.ts with check() (safeParse, 400 with issue paths) and parseBody() (caps body size, handles invalid JSON, 413 when too large). 3. Write only result.data. Take userId, role, plan and price from the session or server-side data. Add select to limit returned columns. 4. Keep auth and ownership checks inside each route and server action. Output the file path and line number of every handler you changed and explain each change in one sentence. Add a test that sends an extra "role" key and expects a 400.
Review the diff before you merge. AI tools sometimes validate and then still write the original body. You can scan the repo again with Leakcade.
Further reading
- OWASP Input Validation Cheat Sheet
- OWASP Mass Assignment Cheat Sheet
- Zod docs: Defining schemas
- Next.js docs: How to think about data security
- Next.js docs: route.js file convention
FAQ
How do I validate a request body with zod in a Next.js route handler?
Read the body with await req.json() in a try/catch (return 400 on invalid JSON), then run schema.safeParse(body). Return a 400 if parsed.success is false, otherwise use parsed.data. The parseBody helper in this guide does this and caps body size.
What is mass assignment with Prisma or Supabase?
It happens when you pass request data straight into data or .update(). Prisma rejects keys that aren't columns but accepts every real column, so a client can set role or plan if they exist. Pick fields with a strict schema instead.
Does z.object reject extra keys?
No. By default z.object strips unknown keys from the output. Use z.strictObject, the Zod 4 form of .strict(), to return an error instead.
Do server actions need validation too?
Yes. The Next.js docs say an exported server action can be reached with a direct POST request. Validate input and check auth inside each action.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms