{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"validate-at-the-edge-and-mass-assignment","title":"Validate at the edge, and the field nobody meant to accept","url":"https://addaly.com/learn/building-apps-with-ai/validate-at-the-edge-and-mass-assignment","position":72,"of":87,"minutes":8,"answer":"A route that spreads the request body into an update accepts every column the caller chooses to send, so every route declares the fields it takes with a schema and ignores the rest.","keyIdea":"A route that spreads the request body into an update accepts every column the caller chooses to send, so every route declares the fields it takes with a schema and ignores the rest."},"body":{"format":"markdown","text":"## The request is whatever the caller says\n\nA 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.\n\nGenerated code skips that job in two ways: it trusts the shape, and it accepts every field.\n\n## Trusting the shape\n\n`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.\n\nTypeScript 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:\n\n```typescript\nimport { z } from \"zod\";\n\nconst UpdateProfile = z.object({\n  name: z.string().trim().min(1).max(80),\n  bio: z.string().max(500).optional(),\n}).strict();                                        // unknown fields are an error\n\nconst parsed = UpdateProfile.safeParse(await request.json().catch(() => null));\nif (!parsed.success) return Response.json({ error: parsed.error.flatten() }, { status: 400 });\n```\n\nOne 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.\n\n## Accepting every field\n\nThis is the one that hands out privileges. The convenient update:\n\n```typescript\nawait db.update(users).set(body).where(eq(users.id, session.userId));\n```\n\nsets 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.\n\nThe 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.\n\nThe 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.\n\n## The fields that must never come from the body\n\nWhatever the schema declares, four things are set by the server, not the caller:\n\n- **Who.** `user_id` from the session, never from the body, as the data block insisted.\n- **When.** `created_at` and `updated_at` from the database default.\n- **Money and state.** `price`, `status`, `paid`, `credits`: computed or transitioned by server logic, never accepted as a value. A checkout route that takes `total` from the body is a shop where everything costs one rupee.\n- **Roles.** `is_admin`, `role`, `plan`: set by an admin action or a payment webhook, and not writable by any route a user can reach.\n\n## Bounds are part of validation\n\nA `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.\n\n## Where the errors go\n\nA 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.\n\n## The check per route\n\nRead 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."},"check":{"question":"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?","options":[{"text":"Edit another user's profile by including a different id in the body.","correct":false,"tempting":"The where clause uses the session id, so the target row is fixed; the hole is in which columns of that row can be written, not which row."},{"text":"Nothing beyond the intended fields, because the form only sends name and bio and the route has no reason to receive anything else from a normal user.","correct":false,"tempting":"The form sends two fields; the route accepts whatever arrives, and a request from the developer tools can carry any field the table has."},{"text":"Set any column on their own row (is_admin, credits, email_verified) by adding it to the JSON they send.","correct":true},{"text":"Crash the server by sending a very large body.","correct":false,"tempting":"A large body is a separate concern handled by size limits; it does not explain a privilege change."}]},"prev":{"slug":"injection-in-three-places","title":"Injection in three places: the query, the shell, the URL","url":"https://addaly.com/learn/building-apps-with-ai/injection-in-three-places"},"next":{"slug":"xss-and-the-dangerously-named-prop","title":"Cross-site scripting, and the prop with the honest name","url":"https://addaly.com/learn/building-apps-with-ai/xss-and-the-dangerously-named-prop"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Validate at the edge, and the field nobody meant to accept', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/validate-at-the-edge-and-mass-assignment (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Validate at the edge, and the field nobody meant to accept","courseTitle":"Building Apps with AI","module":{"index":8,"slug":"the-holes-ai-code-has-by-default","title":"The security holes AI code has by default"},"answer":null,"keyIdea":"A route that spreads the request body into an update accepts every column the caller chooses to send, so every route declares the fields it takes with a schema and ignores the rest.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-request-is-whatever-the-caller-says","runs":[{"text":"The request is whatever the caller says"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"Generated code skips that job in two ways: it trusts the shape, and it accepts every field."}]},{"t":"h2","id":"trusting-the-shape","runs":[{"text":"Trusting the shape"}]},{"t":"p","runs":[{"text":"const { amount } = await request.json()","code":true},{"text":" and then "},{"text":"amount * 1.2","code":true},{"text":". If "},{"text":"amount","code":true},{"text":" is the string "},{"text":"\"abc\"","code":true},{"text":", the result is "},{"text":"NaN","code":true},{"text":", 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 "},{"text":"amount","code":true},{"text":" is missing, it is "},{"text":"undefined","code":true},{"text":", and "},{"text":"undefined * 1.2","code":true},{"text":" is also "},{"text":"NaN","code":true},{"text":". If the body is not JSON at all, "},{"text":"request.json()","code":true},{"text":" throws, and the route returns a 500 with a stack trace."}]},{"t":"p","runs":[{"text":"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:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"import { z } from \"zod\";\n\nconst UpdateProfile = z.object({\n  name: z.string().trim().min(1).max(80),\n  bio: z.string().max(500).optional(),\n}).strict();                                        // unknown fields are an error\n\nconst parsed = UpdateProfile.safeParse(await request.json().catch(() => null));\nif (!parsed.success) return Response.json({ error: parsed.error.flatten() }, { status: 400 });"},{"t":"p","runs":[{"text":"One schema per route, at the top. The route below it works with "},{"text":"parsed.data","code":true},{"text":", which has exactly the declared fields, of the declared types, within the declared bounds. "},{"text":"zod","code":true},{"text":" is the common choice; "},{"text":"valibot","code":true},{"text":" and "},{"text":"arktype","code":true},{"text":" do the same job smaller. Any of them, applied everywhere."}]},{"t":"h2","id":"accepting-every-field","runs":[{"text":"Accepting every field"}]},{"t":"p","runs":[{"text":"This is the one that hands out privileges. The convenient update:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"await db.update(users).set(body).where(eq(users.id, session.userId));"},{"t":"p","runs":[{"text":"sets whatever columns the body names. The form sends "},{"text":"name","code":true},{"text":" and "},{"text":"bio","code":true},{"text":". A request composed by hand sends "},{"text":"{\"name\": \"x\", \"is_admin\": true, \"credits\": 100000}","code":true},{"text":", 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."}]},{"t":"p","runs":[{"text":"The fix is the schema above with "},{"text":".strict()","code":true},{"text":", 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 "},{"text":".set(body)","code":true},{"text":" or "},{"text":".create({ data: body })","code":true},{"text":", that line is the hole."}]},{"t":"p","runs":[{"text":"The same trap in reverse on reads: "},{"text":"select *","code":true},{"text":" returned to the browser sends every column, including "},{"text":"password_hash","code":true},{"text":", "},{"text":"stripe_customer_id","code":true},{"text":" and "},{"text":"is_admin","code":true},{"text":". Select the fields the page shows."}]},{"t":"h2","id":"the-fields-that-must-never-come-from-the-body","runs":[{"text":"The fields that must never come from the body"}]},{"t":"p","runs":[{"text":"Whatever the schema declares, four things are set by the server, not the caller:"}]},{"t":"ul","items":[[{"text":"Who.","b":true},{"text":" "},{"text":"user_id","code":true},{"text":" from the session, never from the body, as the data block insisted."}],[{"text":"When.","b":true},{"text":" "},{"text":"created_at","code":true},{"text":" and "},{"text":"updated_at","code":true},{"text":" from the database default."}],[{"text":"Money and state.","b":true},{"text":" "},{"text":"price","code":true},{"text":", "},{"text":"status","code":true},{"text":", "},{"text":"paid","code":true},{"text":", "},{"text":"credits","code":true},{"text":": computed or transitioned by server logic, never accepted as a value. A checkout route that takes "},{"text":"total","code":true},{"text":" from the body is a shop where everything costs one rupee."}],[{"text":"Roles.","b":true},{"text":" "},{"text":"is_admin","code":true},{"text":", "},{"text":"role","code":true},{"text":", "},{"text":"plan","code":true},{"text":": set by an admin action or a payment webhook, and not writable by any route a user can reach."}]]},{"t":"h2","id":"bounds-are-part-of-validation","runs":[{"text":"Bounds are part of validation"}]},{"t":"p","runs":[{"text":"A "},{"text":"string()","code":true},{"text":" with no "},{"text":"max","code":true},{"text":" accepts a 50 MB bio. A "},{"text":"number()","code":true},{"text":" with no bounds accepts a negative quantity, which some carts turn into a refund. An "},{"text":"array()","code":true},{"text":" 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."}]},{"t":"h2","id":"where-the-errors-go","runs":[{"text":"Where the errors go"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"the-check-per-route","runs":[{"text":"The check per route"}]},{"t":"p","runs":[{"text":"Read the top of every route handler. There should be a schema, a parse, and an early return. Then read every "},{"text":"insert","code":true},{"text":", "},{"text":"update","code":true},{"text":" and "},{"text":"create","code":true},{"text":" 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."}]}],"check":{"question":[{"text":"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?"}],"options":[{"text":[{"text":"Edit another user’s profile by including a different id in the body."}],"correct":false,"tempting":[{"text":"The where clause uses the session id, so the target row is fixed; the hole is in which columns of that row can be written, not which row."}]},{"text":[{"text":"Nothing beyond the intended fields, because the form only sends name and bio and the route has no reason to receive anything else from a normal user."}],"correct":false,"tempting":[{"text":"The form sends two fields; the route accepts whatever arrives, and a request from the developer tools can carry any field the table has."}]},{"text":[{"text":"Set any column on their own row (is_admin, credits, email_verified) by adding it to the JSON they send."}],"correct":true,"tempting":null},{"text":[{"text":"Crash the server by sending a very large body."}],"correct":false,"tempting":[{"text":"A large body is a separate concern handled by size limits; it does not explain a privilege change."}]}],"order":[1,3,0,2],"why":null},"prev":{"slug":"injection-in-three-places","title":"Injection in three places: the query, the shell, the URL","minutes":9},"next":{"slug":"xss-and-the-dangerously-named-prop","title":"Cross-site scripting, and the prop with the honest name","minutes":8}}}