LEAKCADE

Input validation in API routes: zod schemas for Next.js

Updated 6 min read

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.

From request to databaseTwo lanes. In the wrong lane the request body goes straight into a database update, so an extra role field is saved. In the right lane the body is size capped, checked by a strict zod schema, and only picked fields are written.WRONG ORDER1Request bodyarrives2req.json() usedas is3db.update withthe body4role: admin getssavedRIGHT ORDER1Request bodyarrives2Size cap, thenparse JSON3Strict zodschema checks it4Write only thepicked fieldsWRONG ORDER1Request body arrives2req.json() used as is3db.update with the body4role: admin gets savedRIGHT ORDER1Request body arrives2Size cap, then parse JSON3Strict zod schema checks it4Write only the picked fields
Parse first, write second.

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:

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.

BEFORESpreads whatever the client sends
// 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
}
AFTERStrict schema, picked fields only
// 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);
}
Wrong: the client picks which columns change. Right: the schema picks.

What changed:

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.

  1. Write one schema per route

    Name every field. Give strings a .max(), numbers .int().min().max(), choices z.enum([...]), and arrays a .max() on count and on each item. Use z.strictObject for anything that writes data. Never accept userId, role, plan, ownerId or price from the body. Read them from the session or compute them on the server.

  2. Add one shared check helper

    Use safeParse, because parse throws 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 }),
      };
    }
  3. Cap the body size before you parse

    OWASP says to apply size limits before parsing. Check Content-Length first, 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_BYTES to 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.

  4. Validate query strings and params too

    Query values arrive as strings, so numbers need z.coerce.number(). Pass Object.fromEntries(req.nextUrl.searchParams) to check(). For dynamic segments, validate await params the same way, for example with z.object({ id: z.uuid() }).

  5. Repeat it inside every server action

    A server action is a function marked use server that 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 run safeParse on 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 doesSafer move
z.coerce.number()Empty string becomes 0Add .min(1), or preprocess "" to undefined
z.coerce.boolean()The string "false" becomes trueUse 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:

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

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

FIX PROMPT
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

  1. OWASP Input Validation Cheat Sheet
  2. OWASP Mass Assignment Cheat Sheet
  3. Zod docs: Defining schemas
  4. Next.js docs: How to think about data security
  5. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.