The request is whatever the caller says
A route receives a body, and the body is text the caller composed. The form on your page sends two fields; a request from the developer tools, or from a script, sends whatever its author types. The server cannot tell which it received. Everything the route learns from the body is a claim, and the route’s first job is to decide which claims it will accept.
Generated code skips that job in two ways: it trusts the shape, and it accepts every field.
Trusting the shape
const { amount } = await request.json() and then amount * 1.2. If amount is the string "abc", the result is NaN, which is a number as far as the type system is concerned and which the database will store, or reject with an error deep in a query. If amount is missing, it is undefined, and undefined * 1.2 is also NaN. If the body is not JSON at all, request.json() throws, and the route returns a 500 with a stack trace.
TypeScript does not help here. Types describe what the code assumes; they check nothing at runtime, and the body arrives at runtime. The check has to be a real one, at the edge, before the value is used:
import { z } from "zod";
const UpdateProfile = z.object({
name: z.string().trim().min(1).max(80),
bio: z.string().max(500).optional(),
}).strict(); // unknown fields are an error
const parsed = UpdateProfile.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: parsed.error.flatten() }, { status: 400 });One schema per route, at the top. The route below it works with parsed.data, which has exactly the declared fields, of the declared types, within the declared bounds. zod is the common choice; valibot and arktype do the same job smaller. Any of them, applied everywhere.
Accepting every field
This is the one that hands out privileges. The convenient update:
await db.update(users).set(body).where(eq(users.id, session.userId));sets whatever columns the body names. The form sends name and bio. A request composed by hand sends {"name": "x", "is_admin": true, "credits": 100000}, and the route writes all three, because it was told to write the body. It is called mass assignment, it is decades old, and generated code reproduces it because spreading the body is the shortest way to make the form work.
The fix is the schema above with .strict(), or an explicit pick of the allowed fields, and the rule that a route never passes a raw body to an insert or update. If an agent writes .set(body) or .create({ data: body }), that line is the hole.
The same trap in reverse on reads: select * returned to the browser sends every column, including password_hash, stripe_customer_id and is_admin. Select the fields the page shows.
The fields that must never come from the body
Whatever the schema declares, four things are set by the server, not the caller:
- Who.
user_idfrom the session, never from the body, as the data block insisted. - When.
created_atandupdated_atfrom the database default. - Money and state.
price,status,paid,credits: computed or transitioned by server logic, never accepted as a value. A checkout route that takestotalfrom the body is a shop where everything costs one rupee. - Roles.
is_admin,role,plan: set by an admin action or a payment webhook, and not writable by any route a user can reach.
Bounds are part of validation
A string() with no max accepts a 50 MB bio. A number() with no bounds accepts a negative quantity, which some carts turn into a refund. An array() with no length accepts ten thousand items and a route that processes each with a model call. Every field gets a bound, and the bound is the smallest number that serves real users, not the largest the database allows.
Where the errors go
A 400 with the flattened error tells the front end which field to highlight and tells a script nothing useful. A 500 with a stack trace tells a script which library, which file and which line, and that the input was not validated. Validation errors are 400s with field names; everything else is a generic 500 with the detail in your log.
The check per route
Read the top of every route handler. There should be a schema, a parse, and an early return. Then read every insert, update and create and confirm the value passed is the parsed data, or fields picked from it, and never the body. That is the whole audit, and it takes a minute per route.
Every lesson here is open. Your progress is recorded once you pass the Building with AI exam.
Check your understanding
A profile route runs db.update(users).set(body).where(id = session.userId) so that users can edit their name and bio. What can a user do with it?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Beatriz, your AI tutor
