Validating at the Boundary
A user signed up through your public form. Email, a password, nothing unusual. Three weeks later you are staring at a row in the users table where is_admin is true, and nobody on your team put it there.
You go digging. The signup handler is four lines and looks completely harmless:
app.post("/users", async (req, res) => {
const user = await db.user.create({ data: req.body });
res.status(201).json({ id: user.id });
});
The attacker opened devtools, watched the request go out, and resent it with one extra field:
{ "email": "[email protected]", "password": "hunter2", "is_admin": true }
Your server took the whole body and handed it, unread, to the database. The form on your site never showed an “admin” checkbox, so you assumed the body could only contain what the form collected. That assumption is the bug. The form is a suggestion. The request is whatever the client decides to send.
This lesson is about the fix, and it is not “add more if statements”. It is a way of thinking about where untrusted data becomes trusted data, and doing that conversion exactly once, in one place, on purpose.
The network is a hostile place
Here is the rule that everything else follows from. Every byte that arrives over the network is input you did not write, until you have proven otherwise. That includes requests from your own frontend.
People get stuck on that last part. “But I wrote the React app that calls this endpoint, I know it sends a valid age.” Sure. And your React app is one client. The others are: curl, a Python script, Postman, a competitor scraping you, a bug in an old cached version of your own app still running in someone’s tab, and a bot fuzzing every field it can find. Client-side validation is a UX feature. It gives the user a red border before they waste a round trip. It is not a security boundary, because the attacker controls the client and can skip it entirely.
Once you accept that, the shape of the solution appears. There is a line. On one side, data is untrusted and could be anything. On the other side, data has been checked and you can rely on it. Your whole job at the edge is to move data across that line safely, and to make sure nothing sneaks across without being checked. The earlier lesson on reading a request covered how the raw bytes become a req.body in the first place. This lesson is about what you do the instant after.
A handler drowning in checks
The obvious first attempt is to check things by hand. It works, technically, and it teaches you exactly why nobody does it this way for long. Here is a create-user handler that tries to be careful:
app.post("/users", async (req, res) => {
const body = req.body;
if (typeof body !== "object" || body === null) {
return res.status(400).json({ error: "body must be an object" });
}
if (typeof body.email !== "string" || !body.email.includes("@")) {
return res.status(400).json({ error: "invalid email" });
}
if (typeof body.age !== "number" || !Number.isInteger(body.age) || body.age < 13) {
return res.status(400).json({ error: "age must be a whole number, 13 or older" });
}
if (body.role !== undefined && body.role !== "user" && body.role !== "editor") {
return res.status(400).json({ error: "invalid role" });
}
const user = await db.user.create({
data: { email: body.email, age: body.age, role: body.role ?? "user" },
});
res.status(201).json({ id: user.id });
});
Count the problems. The validation is longer than the actual work. Every new field adds four more lines of ceremony. The error messages are inconsistent and only report the first failure, so the client fixes email, resends, and only then learns age is wrong too. You are hand-writing type checks that TypeScript could describe in one line. And here is the part that bites hardest: after all that, body is still typed as any to the compiler, so nothing stops you from reading body.agee two lines later and getting undefined.
There is a subtler failure hiding in the “careful” version. It only survives because you manually cherry-picked email, age, and role into the data object. Forget that discipline once, write data: body because it is shorter, and you are back to the admin-account story. The manual approach makes safety depend on you remembering to be safe every single time.
Parse, do not validate
The fix is a mindset shift with a slightly nerdy name. Validation answers a yes/no question: is this data okay? It returns a boolean and then throws away everything it learned. The value you checked is exactly as untyped afterwards as it was before. So every function downstream that receives it has to wonder all over again whether it is safe, and re-check, and the checks scatter across the codebase like weeds.
Parsing does something better. It takes unknown input and returns a new, precisely typed value, or it fails loudly. After a successful parse you are not holding “the thing I checked”, you are holding a User with an email: string and an age: number, and the type system knows it. The knowledge is captured in the value, not spent on a single if.
A schema is how you describe the parse. You write down the shape you want once, as data, and a library turns unknown input into that shape or reports exactly what was wrong. The dominant tool in the JavaScript world is Zod, whose version 4 has been stable since 2025. Here is the same handler, rewritten:
import { z } from "zod";
const CreateUser = z.strictObject({
email: z.email(),
age: z.number().int().min(13),
role: z.enum(["user", "editor"]).default("user"),
});
app.post("/users", async (req, res) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json(toErrorBody(parsed.error));
}
const user = await db.user.create({ data: parsed.data });
res.status(201).json({ id: user.id });
});
parsed.data is a fully typed object with exactly three keys, and TypeScript knows its type without you writing a single interface. It is inferred from the schema. Passing it straight to db.user.create is now safe, because the schema is strictObject, which means an incoming is_admin does not get quietly stripped and ignored, it makes the parse fail. More on that choice in a moment.
You are not married to Zod. Valibot (stable v1 since March 2025) expresses the same schemas as a tree-shakable pipeline, which matters when bundle size is tight, like in an edge runtime. And thanks to the Standard Schema spec, which Zod, Valibot, and ArkType all implement, framework tooling can accept a schema from any of them through a common ~standard property. Pick the one you like. The thinking in this lesson is identical whichever you choose.
Everything that crosses the boundary
The body is the obvious target, and it is also the one people stop at. A request has four channels of untrusted input, and all four need parsing:
- Body. The JSON (or form) payload. Real types, but attacker-controlled.
- Query string. Everything after the
?. Filters, pagination, search. - Path params. The
:idin/users/:id. Almost always needs to be a number or a UUID, never the literal string"me"unless you decided so. - Headers. Content type, custom
X-headers, anything you branch on. AnAccept-Languagethat you feed into a file path is a directory-traversal waiting to happen.
Give each channel its own schema. A common pattern is one validation middleware that parses all of them and replaces the raw values with parsed ones:
function validate({ body, query, params }) {
return (req, res, next) => {
for (const [key, schema] of Object.entries({ body, query, params })) {
if (!schema) continue;
const result = schema.safeParse(req[key]);
if (!result.success) {
return res.status(422).json(toErrorBody(result.error, key));
}
req[key] = result.data; // hand the rest of the app the typed value
}
next();
};
}
app.get("/users/:id", validate({ params: UserIdParams, query: ListQuery }), handler);
That is the middleware doing exactly one job: turning the request’s untrusted channels into trusted ones before the handler runs. By the time your handler executes, there is no raw input left to be careless with.
Query strings are always strings
Here is the trap that swallows the most people, because it fails silently instead of loudly. The query string has no types. It is text. ?age=37 gives you the string "37", not the number 37. ?admin=false gives you the string "false", and here is the knife:
if (req.query.admin) {
// this runs. "false" is a non-empty string, which is truthy.
grantAdminPanel();
}
The string "false" is truthy. Your guard does the opposite of what it reads like. Nobody catches it in review because it looks correct in English.
Coercion in plain JavaScript is full of these. Number("") is 0, not NaN, so an empty ?limit= silently becomes zero. Number(" ") is also 0. Boolean("0") is true. And if a param appears twice, ?id=1&id=2, most parsers hand you an array, so code expecting a string gets ["1", "2"] and quietly breaks.
The move is to coerce inside the schema, with constraints attached, so that garbage becomes a rejection instead of a wrong value:
const ListQuery = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
published: z.stringbool().optional(),
});
z.coerce.number() runs the value through Number(), which is where the ""-becomes-0 and "abc"-becomes-NaN behavior lives. On its own that is still dangerous. What makes it safe is the chain after it: NaN fails .int(), 0 fails .positive(), 500 fails .max(100). The coercion is allowed to be lax because the constraints are strict. Never coerce without a constraint that can reject the mess coercion produces.
z.stringbool() is the honest way to read a boolean from text. It maps "true", "1", "yes", "on" to true and "false", "0", "no", "off" to false, and rejects anything else, instead of the “any non-empty string is true” landmine you saw above.
Reject unknown keys, or invite mass assignment
Back to the admin account. The vulnerability has a name, mass assignment, and it happens whenever untrusted keys flow straight into an object you trust, usually a database update. The fix is a policy decision your schema makes about keys it was not told to expect.
Zod objects give you three policies:
z.object({...})strips unknown keys. The parse succeeds, andis_adminis silently dropped fromparsed.data. Safe, quiet.z.strictObject({...})rejects unknown keys. The parse fails with an error naming the offending key. Safe, loud.z.looseObject({...})passes them through.is_adminsails intoparsed.data. This is the dangerous one, and it should be a deliberate, rare choice.
Which should you pick? For a public API, strictObject is the better default. A rejected request tells an honest client “you sent a field I do not understand, fix your call”, and it tells an attacker nothing useful while blocking the attack. The one place strict bites you is versioning: if a newer client starts sending a field your older server does not know yet, strict rejects the whole request. If you need that forward-compatibility, z.object (strip) is the pragmatic middle. What you almost never want is looseObject feeding a database write. That is the original sin from the top of this lesson, written down as a schema.
From a failed parse to a response the client can use
A parse failure is not an exception to swallow. It is information the client needs, and there is a right way to hand it back. safeParse returns a discriminated result, so you branch on success and reach into error.issues, an array where each entry carries a path (which field), a message (what was wrong), and a code:
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) {
console.log(parsed.error.issues);
// [
// { code: "invalid_format", path: ["email"], message: "Invalid email address" },
// { code: "too_small", path: ["age"], message: "Too small: expected >= 13" }
// ]
}
The path array is the gold. It tells the client precisely which field failed, including nested ones like ["address", "zip"]. Map every issue into a field-keyed object so a form can light up the right inputs:
function toErrorBody(error, source = "body") {
const fieldErrors = {};
for (const issue of error.issues) {
const key = issue.path.join(".") || source;
(fieldErrors[key] ??= []).push(issue.message);
}
return { error: "validation_failed", source, fields: fieldErrors };
}
Zod 4 ships helpers so you do not always hand-roll this: z.treeifyError(error) gives you a nested object mirroring the schema, z.flattenError(error) gives you a flat fieldErrors map for single-level forms, and z.prettifyError(error) gives a human-readable string for logs. Use whichever matches the client you are serving.
Now the status code, which people argue about. A malformed request that the server genuinely cannot parse (broken JSON) is a 400 Bad Request. A request that is syntactically fine JSON but fails your business rules (age of -5, missing email) is where many APIs prefer 422 Unprocessable Content, because the server understood the request, it just will not process it. Both are defensible. What matters is that you pick one, apply it consistently, and always return a body that names the fields. The full argument for a stable error contract lives in status codes and errors; validation is the single largest producer of 4xx responses, so it is where that contract earns its keep.
Build the validator yourself
To make “a schema is just data plus a parse function” concrete, here is a validator with no library at all. Edit the payload, hit validate, and watch it coerce good values, reject bad ones with field paths, and refuse the unknown isAdmin key. The rule set on the right is the entire “schema”:
Notice the two errors the default payload produces: role is "admin", which is not in the allowed set, and isAdmin is an unknown key that gets rejected outright. Change role to "user" and delete the isAdmin line, and it parses, handing back age as the number 37 rather than the string it came in as. That little validate function is, in miniature, everything a schema library does for you: coerce, constrain, reject unknowns, and report failures by path.
Where validation stops
A parsed request is not an authorized one, and confusing the two is a classic mistake. Your schema can confirm that POST /accounts/42/transfer has a well-formed amount and a valid toAccount. It cannot tell you whether the person making the request is allowed to move money out of account 42. That is authorization, a separate check against who the caller is and what they may do, and it is covered in authorization. Validation asks “is this input well-formed?” Authorization asks “may you do this?” You need both, and passing one tells you nothing about the other.
The other line worth drawing is between compile-time types and runtime reality. TypeScript is a wonderful design tool and it vanishes completely when your code runs. A function typed to accept a User will happily receive a JSON blob shaped like a llama at runtime, because types are erased during compilation and the network does not read your .ts files.
This is why validation is not optional plumbing you bolt on when you have time. It is the one place where the type system’s promises get made real, the seam where “the compiler believes this is a User” becomes “this genuinely is a User”. Put a schema on every entrance to your program and the entire inside gets to be honest.
Summary
- Everything from the network is untrusted, including your own frontend. Client-side validation is a UX nicety; anyone can curl your API and skip it. The real check has to live on the server.
- Parse, do not validate. Turn unknown input into a typed value once, at the edge. A schema captures the result in the value’s type, so downstream code stops being defensive instead of re-checking everywhere.
- Validate all four channels: body, query, path params, and headers. A validation middleware that replaces raw values with parsed ones keeps handlers clean.
- Query strings are always strings.
"false"is truthy andNumber("")is0. Coerce inside the schema with constraints attached (z.coerce.number().int().min(1)), and read booleans withz.stringbool, so garbage becomes a rejection, not a silent wrong value. - Reject unknown keys with
strictObject(or strip them withz.object) to shut down mass assignment. Never spread a raw body into a database write; let the schema be your allowlist. - Map failures to a real response.
safeParsegives youerror.issueswith apathper field; fold them into a400/422body keyed by field. See status codes and errors for the contract. - Validation is not authorization (authorization is a separate concern) and types are not runtime checks (types get erased). A schema is the seam where the two are reconciled.