Backend in Depth
Validation at the Edge: Schemas, DTOs and Trusting Nothing
Check input where it enters the API, share schemas with the frontend, and keep validation errors apart from business-rule errors.
On this page
- Words used in this lesson
- Why validate on the server (and everywhere)
- Parse, don't validate
- Query parameters and path params are strings
- Mass assignment (over-posting)
- Validation errors vs business-rule errors
- Share types and schemas with the frontend
- Other input hygiene
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: treat every byte from outside your process as unsafe until a schema says it is fine. Validate at the edge. Turn the input into a trusted, typed object ("parse, don't validate"). Let the database constraints be the last line of defence.
You already validate forms in the frontend. Backend validation is a different job. The browser's checks are a convenience for honest users. The server's checks are the security boundary. Anyone can call your API with curl (a command-line tool for sending HTTP requests) and send anything.
Words used in this lesson
- Validation means checking that data has the right shape and values before you use it. Example:
qtymust be a whole number from 1 to 100. - The edge is the place where data first enters your program from outside, such as the HTTP request handler.
- A schema is a written description of what shape data must have: which fields, which types, which limits.
- Zod is a TypeScript library for writing schemas. It checks data at runtime (while the program runs) and also gives you the matching compile-time type (the type TypeScript checks before the program runs). TypeScript types disappear after compiling, so they cannot check data that arrives over the network. A runtime schema can.
req.params,req.queryandreq.bodyare the parts of an Express request: values from the URL path (/orders/7), values after the?in the URL, and the data sent in the request body.- A constraint is a rule the database itself enforces.
NOT NULLforbids empty values,UNIQUEforbids duplicates,CHECKtests a condition, and a foreign key requires that a reference points to a row that exists. - An allow-list is a list of the only values you accept. Everything else is refused. (The opposite, a block-list, tries to list bad values and always misses some.)
- Authentication vs authorization: authentication asks "who are you?", and authorization asks "may you do this?".
Why validate on the server (and everywhere)
| Where | Purpose | Trustworthy? |
|---|---|---|
| Client (browser) | Fast feedback, better user experience | No. It is easy to bypass |
| Server (API edge) | Reject broken or harmful input before it reaches logic | Yes. This is the check that counts |
| Business layer | Rules that need data ("is the coupon still valid?") | Yes |
| Database | Constraints: NOT NULL, UNIQUE, CHECK, foreign keys | Yes, the final safety net |
So, where should validation happen: client, server or database? The answer is all three, for different reasons. The server edge is the main guard.
Parse, don't validate
Parse, don't validate is a design idea. Do not just check a value and then keep passing around the same unchecked data. Instead, parse the raw input into a new value that is typed and trusted. After parsing, the rest of your code does not need more defensive checks.
import { z } from "zod";export const createOrderSchema = z.object({ items: z .array(z.object({ sku: z.string().min(1).max(64), qty: z.number().int().min(1).max(100) })) .min(1) .max(50), couponCode: z.string().trim().toUpperCase().max(32).optional(), note: z.string().trim().max(500).optional(),}).strict(); // reject unknown fieldsexport type CreateOrderInput = z.infer<typeof createOrderSchema>; // the TypeScript type comes from the schemaz.infer is a Zod helper that reads a schema and produces the TypeScript type for it. By default, a Zod object removes unknown keys from the result without an error. .strict() makes unknown keys an error instead. So the schema is the single source of truth. It gives you both the runtime checks and the compile-time types. (Zod is one popular choice. Valibot, ArkType, Joi, Yup and TypeBox use the same idea.)
Zod 3 vs Zod 4. The examples use the older chained style (.strict(), z.string().uuid()). It still works. Zod 4 moves these to top-level helpers (z.strictObject({ … }), z.uuid(), z.email()) and marks the chained forms as deprecated. Its z.uuid() is also stricter than the old check. Use the style that matches the version in your project.
A reusable validation middleware
Middleware is a function that runs before your route handler. This one checks the params, query and body with schemas. safeParse is a Zod method that returns { success: true, data } or { success: false, error } instead of throwing.
import type { RequestHandler } from "express";import type { ZodTypeAny } from "zod";import { ValidationError } from "../shared/errors";export const validate = (schemas: { body?: ZodTypeAny; query?: ZodTypeAny; params?: ZodTypeAny }): RequestHandler => (req, _res, next) => { const issues: { where: string; path: string; message: string; code: string }[] = []; for (const key of ["params", "query", "body"] as const) { const schema = schemas[key]; if (!schema) continue; const result = schema.safeParse(req[key]); if (result.success) { if (key === "body") req.body = result.data; // replace with the parsed, typed value else (req as any)[`valid${key[0].toUpperCase()}${key.slice(1)}`] = result.data; } else { for (const i of result.error.issues) issues.push({ where: key, path: i.path.join("."), message: i.message, code: i.code }); } } if (issues.length) return next(new ValidationError("Validation failed", issues)); next(); };In Express 5, req.query is read-only (a "getter": a property that is computed each time you read it). You cannot assign the parsed result back to it. In an ES module or strict mode, the assignment throws a TypeError. Store the parsed values on another property (as above) or in res.locals. Many Express 4 examples reassign req.query. That no longer works.
Query parameters and path params are strings
Everything in a URL arrives as a string. Convert values on purpose. In Zod, z.coerce.number() converts a string to a number before checking it:
export const listOrdersQuery = z.object({ status: z.enum(["pending", "paid", "shipped", "cancelled"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().max(200).optional(), sort: z.enum(["createdAt", "-createdAt", "total", "-total"]).default("-createdAt"),});export const idParams = z.object({ id: z.string().uuid() });An allow-list enum (a fixed list of allowed values) for sort and status also protects against injection. Injection is an attack where someone sneaks code, such as SQL, into input so that your program runs it. Only known values reach the query builder.
Mass assignment (over-posting)
Mass assignment is a bug where the server copies every field from the request into a database record, so a client can set fields it should not. It is also called over-posting.
Scenario: the frontend sends an extra field, isAdmin: true. Look at this code:
// ❌ DANGEROUS: whatever the client sent goes straight into the databaseawait prisma.user.update({ where: { id }, data: req.body });A bad client can now set fields you never meant to allow: isAdmin, role, balance, emailVerified, ownerId.
How to defend:
- Allow only listed fields, using the schema. Build the update object from the parsed result (it contains only allowed keys). Never build it from raw
req.body. Also,.strict()rejects unknown keys completely. - Use a separate schema for each use case.
updateProfileSchema(name, avatar) is not the same as an admin'supdateUserRoleSchema. - Never let the client decide who owns something, or set values that only the server should set. Take
userIdfrom the logged-in session (the server's record of who is logged in). Set timestamps and status on the server. - Keep the DTO separate from the database model. A DTO (data transfer object) is a plain object that has exactly the shape of the API contract. The database entity (the object for one stored row) has more: internal flags, hashes, audit fields. Map between them on purpose. Return only the fields you intend to show (never
passwordHash).
const updateProfileSchema = z.object({ displayName: z.string().min(1).max(60), bio: z.string().max(280).optional() }).strict();const data = updateProfileSchema.parse(req.body); // only these two keys can passawait prisma.user.update({ where: { id: req.user!.id }, data }); // id comes from the session, not the bodyValidation errors vs business-rule errors
Keep these two apart, because the client reacts in different ways:
| Kind | Example | Status | Client action |
|---|---|---|---|
| Broken or invalid input | qty: -3, invalid email, missing field | 400 or 422 with field errors | Highlight the field |
| Business-rule violation | "Insufficient stock", "Coupon expired", "Order already shipped" | 409 or 422 with a domain code | Show a message and offer another option |
| Not found / not allowed | Unknown order, someone else's order | 404 / 403 | Go to another page or show an access error |
Choose 400 or 422 for validation one time, and then stay consistent (HTTP semantics). Return paths, so the frontend can match each error to an input:
{ "status": 422, "title": "Validation failed", "errors": [{ "path": "items.0.qty", "code": "too_small", "message": "Too small: expected number to be >=1" }] }Share types and schemas with the frontend
The contract is the agreement about what the API accepts and returns. "Contract drift" is a classic bug: the two sides slowly stop matching. The backend changes a field, and the frontend breaks in production. Here are the options, from simplest to strongest:
- A shared package in a monorepo (one repository that holds several projects, for example
packages/schemas). It exports Zod schemas and inferred types. Both the Next.js app and the API import it. There is one definition, so the two sides cannot drift. - OpenAPI as the source of truth. OpenAPI is a standard file format that describes every endpoint of an API. You generate TypeScript types and clients from it (
openapi-typescript). Or you use tRPC, a library that lets a TypeScript frontend call backend functions with shared types, for TypeScript-only stacks. - Generate the OpenAPI spec from the schemas (for example with
zod-to-openapi), so the documentation is never out of date.
If the client uses the same schema, forms give instant feedback. The server still validates on its own.
Other input hygiene
- Limit the body size (
express.json({ limit: "100kb" }); 100kb is also the default) and the number of array items. Input with no limit can be used for a denial-of-service attack (making your server too busy to answer). - Check
Content-Type. This header says what format the body is in (for exampleapplication/json). Reject types you do not expect with415(Unsupported Media Type). Note thatexpress.json()skips a body with another type and leavesreq.bodyempty, so add this check yourself. - Validate and clean early: trim strings, lowercase emails, and convert dates to
Dateone time. - Sanitise only where it makes sense. Sanitising means cleaning input, for example by removing characters. Validation decides whether the data is acceptable. Escaping/encoding means changing special characters so they are treated as plain text, and it happens at output (HTML, SQL parameters). Do not try to remove "bad characters" as your security plan.
- Use parameterised queries. A parameterised query sends the SQL text and the values separately, so a value can never be run as SQL. Use the ORM (a tool that lets you use the database from code) or
$1placeholders. Never build SQL by joining strings. (Prisma's tagged$queryRawtemplate is parameterised.$queryRawUnsafewith interpolation is not.) - Validate files: size, declared type, detected type and extension. Store them outside the web root (the folder the web server serves directly) or in object storage (a service for storing files, such as Amazon S3) (Practical Design Problems, Lesson 04).
- Validate configuration at startup (
env.tswith a schema). If a secret is missing, the process crashes right away, not at 3 a.m.:
export const env = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), JWT_PUBLIC_KEY: z.string().min(1),}).parse(process.env);- Let the database enforce the rules too:
UNIQUE (email),CHECK (qty > 0),NOT NULL, foreign keys. Application checks can race. A race condition happens when two requests run at the same time and both pass a check such as "does the email exist?", before either one saves. A unique constraint cannot be beaten this way, because the database applies it atomically (as one indivisible step). Catch the constraint error and turn it into a409.
Common mistakes
- Trusting the frontend's validation.
- Spreading
req.bodyinto database writes (mass assignment). - Validating, but then using the original untyped input instead of the parsed result.
- No size limits on bodies, arrays and strings.
- Forgetting that query and path params are strings (
"0"and"false"are non-empty strings, so both are truthy, and neither is a number or a boolean). - Mixing validation errors and business errors and returning the same status for both.
- Check-then-insert races with no unique constraint behind them.
- Hand-written regexes (regular expressions, text patterns) for emails and URLs instead of tested libraries and
new URL().
Interview questions
Where should validation happen: client, server or database?
All three. The client validates for a better user experience. The server is the main security boundary, and it validates every request. The database enforces rules (unique, not null, check, foreign keys), because application checks can race. The server edge is where untrusted data first becomes trusted, typed data.
Scenario: the frontend sends an extra isAdmin field. What stops mass assignment?
I never pass raw req.body to the database. A schema says exactly which fields each endpoint accepts (.strict() rejects extra ones). I build the write from the parsed output. Values that only the server should control (userId, role, timestamps) come from the session or from code, never from the client. I use a separate schema for each use case, so an ordinary user cannot even ask for an admin-only change.
How do you share types between Next.js and the API?
I put the schemas in a shared package in a monorepo and infer the TypeScript types from them (z.infer). Both sides then use one definition. Or I treat an OpenAPI document as the contract and generate types and clients from it. Either way, contract drift becomes a compile-time error. The server still validates on its own.
What does "parse, don't validate" mean?
I do not just check a value and then keep using the same untyped data. I turn the raw input into a new, typed value that is valid by construction. The rest of the code receives the parsed type, so it cannot use unchecked data by accident. It also does not need to repeat the checks.
How do you differentiate validation errors from business-rule errors?
A validation error means the request is broken or breaks a declared rule (400 or 422 with field paths). A business-rule error means the request is well-formed but conflicts with the current state of the domain (409 or 422 with a domain code such as INSUFFICIENT_STOCK). Different codes let the client highlight fields in the first case and show a message in context in the second.
Why do you still need database constraints if the app validates?
Application checks can race. Two requests at the same time can both pass a "does this email exist?" check. Other code paths (scripts, other services) can also have bugs. A unique constraint or check constraint is atomic (it happens fully or not at all) and cannot be bypassed by application code. The app catches the violation and turns it into a 409.
What are the risks of unbounded input?
Huge JSON bodies, deeply nested objects, very long strings and very large arrays use a lot of memory and CPU. This allows denial of service (an attack that makes your server too busy to answer real users). I set limits on request size, array length and string length. I also set maximum page sizes and timeouts.
How do you stop SQL injection in a Node API?
I use parameterised queries through the ORM or the driver's placeholders. I never build SQL by joining strings. For names that cannot be parameterised (sort columns, table names), I use an allow-list. I give database accounts only the rights they need. I treat raw-query escape hatches ($queryRawUnsafe) as red flags in code review.
Scenario: the frontend sends dates as strings in four different formats and some are silently wrong. What do you do?
I define one format for the wire (ISO 8601, UTC) in the API contract and in the schema. I reject everything else with a clear 422, and I convert once at the boundary. I never rely on new Date(string) to guess formats. I share the schema with the frontend, so the format cannot drift. I add tests for time zones and daylight-saving edge cases.
Key takeaways
- Validate at the edge, on the server, always. Client checks are for user experience, not security.
- Parse into typed objects with a schema, and let
z.infergive you the types. - Defend against mass assignment with strict schemas for each endpoint and fields that only the server sets.
- Keep validation errors and business-rule errors apart, and return field paths.
- Share schemas (monorepo or OpenAPI) so the frontend and backend cannot drift.
- Limit sizes, parameterise queries, validate config at startup, and back everything with database constraints.
What's next
Lesson 04: Error Handling: Operational vs Programmer Errors. Inputs are checked. Now, what happens when things still go wrong? We look at typed errors, central handling, how Express 5 handles async code, and when to let the process crash.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Prompt Injection and Safe Tool Use for Agents · AI Engineering
- Request and Response Design: JSON Shapes, Errors and Validation · API Design
Part of the study guide
- API Design Guide: REST, Errors, Pagination and Versioning
- Node.js Guide: Event Loop, Streams, Servers and Production
Related series