An API route that saves whatever JSON arrives can store a role field no user should set, or a script tag a shared dashboard later renders as code. The input validation web app builders skip is one rule: every write endpoint checks the body against a server-side schema, with Zod or Pydantic, and keeps nothing it did not name.
What is input validation? For a web app, a schema on every write endpoint
Input validation is the server checking every request against an explicit schema before any code uses it: the fields it expects, their types, lengths, ranges and formats, and nothing else. In a web app it runs on every write endpoint, and a request that fails gets a 4xx response with nothing stored.
API validation and server-side input validation are this same check, named from the API side. It is one of the controls that make up web app security, and input validation in web application security is the control that decides what your database will accept from a stranger.
“Every write endpoint” in a builder’s app means more places than the obvious one: an API route, a server action, a form handler, a webhook, an edge function, or a database function the browser calls directly. How to validate an API request body is the same in each: parse the body with the schema first, use only the parsed result, and return the errors when the parse fails. Schema validation on server actions follows the same rule. Next.js’s forms guide says that “For server-side validation, you can use a schema validation library like Zod or Valibot to validate the form fields,” and its example action returns early if the form data is invalid.
What the schema should do with each kind of bad input is below. The response column is my rule, not a framework default: “a 4xx” means a 400 or a 422, never a 500.
| What arrives | What the schema does | The response |
|---|---|---|
| A wrong type: a string where a number belongs | Rejects it | A 4xx naming the field |
| A missing required field | Rejects it | A 4xx naming the field |
| A value out of range, such as a negative quantity | Rejects it | A 4xx |
| An oversized value | Rejects it on its length bound | A 4xx |
| A malformed identifier or date | Rejects it on its format | A 4xx |
| A field the schema does not list | Rejects it under a strict schema, or strips it (what Zod’s z.object() does by default) | A 4xx, or success with the field never stored |
Laravel puts a number on it: a 422 with the validation errors as JSON when the failed request expects JSON. FastAPI’s request body docs promise “a nice and clear error” for invalid data without naming a status code; the custom handler in their error-handling example returns 422, and the generated OpenAPI shown in their additional responses docs lists a 422 “Validation Error” response.
OWASP’s Input Validation Cheat Sheet says “Allowlist validation is appropriate for all input fields provided by the user,” which is why the table rejects what it did not name instead of hunting for bad characters. The same sheet names the two types of input validation: syntactic, which enforces the correct syntax of structured fields such as a date, and semantic, which enforces correct values in the business context, such as a start date before an end date. A schema carries both.
Schema validation at runtime is this check at the boundary, run on the data that actually arrives. Compile-time types, the subject of strict typing, are gone by the time a request comes in, so a number in your TypeScript says nothing about the JSON a stranger sends.
API response validation is the same idea on the way out. OWASP’s API3:2023 entry recommends that you “Implement a schema-based response validation mechanism as an extra layer of security.”
The input validation checklist for APIs is the table above plus the seven payloads in the verify section below. The validation best practices behind it fit in one line, and they are the way to implement validation rules on any stack: allowlist, reject early, and use the same schema everywhere the input enters.
What input validation web app builders skip, and what goes wrong without it
Across the 21 public and held-out third-party apps I audited in June and July 2026, the Input, Injection & Abuse pillar averages 61.9 out of 100. Those 21 are a selected set of apps I audited, not a random sample and not a rate for AI-built apps in general. Why input validation is important in web application security comes down to one fact: the request is the only part of the app the user fully controls.
Four failures follow when an endpoint trusts the body:
| Failure | What the owner sees | The fix and where it lives |
|---|---|---|
| Mass assignment | A role or a price the caller added is saved to the record | An allow-list of writable fields, set out in the two mass assignment sections below |
| Stored input on another page | A name or a note renders as code for other users | Bounds on the way in, escaping on the way out |
| A corrupted record | A string where a number belongs, a date in the wrong format, a negative quantity; the app breaks later, far from the cause | The schema table above |
| The injection path | A quote typed in a search box reaches a database query, or the same text reaches a model prompt | Parameterized queries, covered in SQL injection prevention; model input controls, covered in what is prompt injection |
Mass assignment vulnerability: the request body copied into the record
A mass assignment vulnerability is an endpoint that copies the request body into a database record, so a field the form never shows, such as a role or a price, is saved when a caller adds it. OWASP’s API Security Top 10 files it under API3:2023, Broken Object Property Level Authorization.
OWASP API3:2023 Broken Object Property Level Authorization calls an endpoint vulnerable when it lets a user change, add or delete a sensitive property they should not be able to access, the half it says was previously named Mass Assignment (the 2019 list’s API6:2019). OWASP’s mass assignment advice is direct: “If possible, avoid using functions that automatically bind a client’s input into code variables, internal objects, or object properties,” and “Allow changes only to the object’s properties that should be updated by the client.”
MITRE catalogs the weakness as CWE-915, Improperly Controlled Modification of Dynamically-Determined Object Attributes. Mass assignment vulnerabilities also go by other names, and OWASP’s Mass Assignment Cheat Sheet lists them: mass assignment in Ruby on Rails and NodeJS, autobinding in Spring MVC and ASP NET MVC, and object injection in PHP.
Why AI-built code does this is my reading, not a measured claim. The shortest code that works passes the whole body into the insert or update call: the request body spread into the record, or a client update call handed the body exactly as it arrived. It works in every demo, because the demo form never sends the extra field. The fix is the allow-list of writable fields further down this page.
User input showing up on another page
This is the stored case. A display name, a company name or a note is saved exactly as typed, a shared view renders it as HTML, and it runs as script for everyone who opens that view. Validation bounds what gets stored: a length on every field, and a format only where the business has one, such as an email, a slug or an ID. Free text stays free. Escaping on render is what stops the script. In my reading, most templating frameworks escape by default, and the usual cause is the raw-HTML escape hatch someone reached for to show formatting. The output contexts and the content security policy backstop are on the page about how to mitigate cross site scripting.
Can you give me an example of an input validation vulnerability?
Yes. In one AI coding workspace I audited, the delete-file route pasted the user-supplied path into the database filter grammar instead of passing it as a value. The report bounds it honestly: the user-id filter and row-level security kept it inside the caller’s own workspace, but a crafted path could delete more of their own files or break the query.
The lesson I take from it: a value that reaches a query as grammar instead of data is unchecked input, even when another control caps the damage.
A worse case from the same 21 apps I audited in June and July 2026, one app of the 21 and not a trend: an endpoint where a single web request drops the entire production database. It is told under the privileged-routes check of the API security checklist, which also covers the prices, roles and ownership the server must compute itself.
How to do it on the common stacks
Server-side validation in any stack comes down to the same habits: parse every write through a schema, use only the parsed result, set owner, role, tenant and price from the session instead of the body, and escape user text when a page renders it. The library changes by stack; the habits do not.
The library behavior below is taken from each project’s own documentation, quoted where a default or a status code is involved.
Schema validation libraries: Zod, Joi, Ajv and the equivalents in other stacks
Schema validation libraries exist for every common stack: Zod, Joi, Ajv or TypeBox in Node and Next.js, Pydantic in FastAPI, form requests in Laravel, go-playground/validator in Go. Each one checks only what the handler passes it, so the rule is that no handler reads the raw request body.
| Stack | Library | How it describes itself | What a failed check returns |
|---|---|---|---|
| Node and Next.js | Zod’s documentation gives the install as npm install zod; Zod runs in Node.js and all modern browsers | ”TypeScript-first validation library” | .safeParse() returns a result holding the parsed data or a ZodError; .parse() throws the ZodError |
| Node | Joi | ”The most powerful schema description language and data validator for JavaScript” | schema.validate() returns { error, value } |
Node (the ajv npm package) | Ajv | ”Ajv JSON schema validator”, for JSON Schema and JSON Type Definition | The validation function’s errors property, overwritten on every call |
| Node | TypeBox | ”JSON Schema Type Builder with Static Type Resolution for TypeScript” | Its compiled Check() returns a boolean |
Node (the npm validator package) | validator.js | ”A library of string validators and sanitizers”; it checks strings, not a request schema | Not a request-level result: each validator answers for one string |
| Python, FastAPI | Pydantic models; FastAPI validation comes from declaring the model as the body parameter, per FastAPI’s request body docs | Pydantic: “the most widely used data validation library for Python" | "a nice and clear error”; the request body docs name no status code |
| Python, Flask | A Pydantic model called in the view | Flask’s design docs say that by default it “does not include a database abstraction layer, form validation or anything else” other libraries handle, so Flask API validation means calling the model yourself | Pydantic raises a ValidationError with a list of errors |
| PHP, Laravel | Form requests, per Laravel’s form request validation | ”custom request classes that encapsulate their own validation and authorization logic” | A 422 with the errors as JSON when the request expects JSON |
| Go | go-playground/validator | ”value validations for structs and individual fields based on tags” | nil or ValidationErrors |
| .NET | Minimal API validation in ASP.NET Core minimal APIs, ASP.NET Core 10.0 | Once validation is enabled with AddValidation(), attributes from System.ComponentModel.DataAnnotations, applied to the query, headers and request body | ”a 400 - Bad Request response with details of the validation errors” |
Zod vs Joi, or TypeBox vs Zod, is a choice between pieces of software that do the same job. Zod infers TypeScript types from the schema, TypeBox builds JSON Schema objects that infer as TypeScript types, and Joi is a schema description language and data validator for JavaScript. My working rule is to keep the one the codebase already uses and choose Zod for a new TypeScript codebase.
For REST APIs, AWS API Gateway request validation can check a request against a model schema before it reaches the backend, and when validation fails, API Gateway “returns a 400 error response to the caller.” Treat that as a second copy of the schema at the edge, never the only one.
A REST API validation example, as a Next.js route handler: a strict Zod object for a profile update that allows a display name and a bio and nothing else, so a role field in the body fails the parse.
import * as z from "zod";
const ProfileUpdate = z.strictObject({
displayName: z.string().min(1).max(80), // bounds are yours to set
bio: z.string().max(500),
});
export async function PATCH(request: Request) {
const result = ProfileUpdate.safeParse(await request.json().catch(() => null));
if (!result.success) {
return Response.json(z.flattenError(result.error), { status: 400 });
}
const userId = await getSessionUserId(); // your auth helper, never the body
await updateProfile(userId, result.data); // parsed fields only
return Response.json({ ok: true });
}
The handler never touches the raw body after the parse. z.flattenError() gives a fieldErrors object with an array of messages for each field, which is what lets the response name the field that failed.
Mass assignment: the allow-list of writable fields
Four rules stop mass assignment, and they hold in any framework:
- 01 The schema for each write lists only the fields that user may set on that action, and nothing else.
- 02 The handler writes the parsed result, never the raw body or a spread of it. A strict schema rejects extra fields where the client has no reason to send them; my working rule prefers rejecting to stripping on write endpoints, because a rejection shows up in tests.
- 03 Owner, role, tenant, price, plan and status come from the session or the server’s own lookup, never from the body.
- 04 The framework’s own guard is switched on where one exists: strong parameters in Rails, the
Fillableattribute on Laravel’s Eloquent models.
Rails strong parameters make Action Controller parameters “forbidden to be used in Active Model mass assignment until they have been explicitly enumerated,” and the recommended form on that page lists the permitted keys with params.expect. Laravel’s Eloquent mass assignment docs require “either a Fillable or Guarded attribute on your model class” before you use the create method, because “all Eloquent models are protected against mass assignment vulnerabilities by default.” For other frameworks, the OWASP cheat sheet named above has a section each, from Spring MVC to Django. The test is the extra-field payload in the verify list below.
Browser validation is not validation
Browser validation is a courtesy to the user, not a control. The required, pattern, min and max attributes and any JavaScript check run in a page the user controls, and a request sent from a terminal skips them. The server repeats every check; the browser copy only shows the error sooner.
Input validation in computer science means checking input against rules before the program uses it (the five classic checks are in the questions at the end), and in a web app that check only counts when the server runs it.
HTML form input validation is the HTML standard’s constraint validation: the required, pattern, min, max and type attributes, so HTML input number validation is type="number" with min and max. The same standard says “Servers should not rely on client-side validation,” and that these features “are only intended to improve the user experience, not to provide any kind of security mechanism.”
MDN’s form validation guide splits client-side input validation into HTML form validation and JavaScript form validation, and warns that it “is too easy to bypass.” To make input validation in HTML or JavaScript useful, treat it as a user-experience feature: the server copy is the control.
The UI validation best practice is to apply validation in the HTML form from the same rules the server schema uses, so the messages match. Zod works in Node.js and all modern browsers, so one schema can serve both sides. For form errors, the W3C design system’s form errors guidance says to “Prefix the word ‘Error:’ to the document’s <title>,” to “Place an error summary at the top of the page” and to “Add an error message to each problematic input.” Keeping what the user typed, and never echoing input back unescaped, are my own rules on top.
The quickest test for a builder’s app: send one of the form’s requests again from outside the page, with one field changed to a value the form would never allow. The curl request in the next section is that test, and the server must refuse it. The prompts that change what your AI builder writes include one that asks for the server-side check.
How to verify it: seven malformed payloads
Seven malformed payloads prove a write endpoint holds: a wrong type, a missing required field, a field the user may not set, an oversized value, a malformed identifier, a script tag with a quote, and an instruction-like string. Pass means a 4xx or a safe store, never a 500, and nothing saved that the schema did not allow.
Run them against your own staging copy, or your own production app with your own test account, never against someone else’s. This is what schema validation in API testing means for a founder: the endpoint’s schema, tested with inputs that must fail. To test an endpoint with a malformed payload, take each write endpoint through the list below and keep the evidence named with each item.
- 01 A wrong type. A string where a number belongs. Pass: a 4xx naming the field. Keep the request and the response.
- 02 A missing required field. Pass: a 4xx naming the field. Keep both.
- 03 An extra field the user must not set. Add
role, an owner ID or a price to a normal request. Pass: a 4xx under a strict schema, or a success with the field dropped. Either way, re-read the record and show the field unchanged. Keep the before and after rows. - 04 An oversized value. A string of about 100 KB in a name field, my working rule for the test size. Pass: a 4xx, such as a 400 or a 413, and nothing stored. Keep the response and a count of your test account’s records before and after; a table-wide count on a live app moves with other users’ writes.
- 05 A malformed identifier. A value that is not a UUID where the route expects one. Pass: a 4xx, and no database error in the server log for that request. Keep the log line, or its absence, for that timestamp.
- 06 A script tag and a single quote in a free-text field. Pass: refused by the field’s bounds, or stored and shown as literal text on every page that renders it, with no query error in the log. Keep a screenshot of the rendered page.
- 07 An instruction-like string in any field a model reads. Pass: handled by the AI controls the prompt injection page covers. Keep the model’s output.
MDN defines the 413 as a request “larger than limits defined by server,” which is the right answer to payload 4 when the limit sits in front of the schema. Payload 3 goes out like this, with your own session token (or the cookie your browser sends) in place of the variable:
# payload 3: the role field must not change
curl -i -X PATCH https://staging.example.com/api/profile \
-H "Authorization: Bearer $YOUR_TEST_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"displayName":"Test","role":"admin"}'
Pass, across all seven, means no internal server error anywhere, nothing stored that the schema did not allow, and messages that name the field. Keep the seven requests and responses for each endpoint, with the date. The same seven belong in the suite from how to write end-to-end smoke tests and among the request-level cases of an API security assessment. A burst of the same request is a different test: rate limiting in API routes.
In the sprint, deliverable 3.1, server-side request validation, is verified this way: we submit invalid and hostile payloads and confirm safe rejection or handling.
Where the sprint does this
Deliverable 3.1 validates every data-writing endpoint with explicit schemas and safe input and output handling, including injection defenses. The result and its evidence go into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Where it stops: new features that change the product’s core capabilities are separate work, and the sprint includes only the supporting interfaces the listed controls need. Every deliverable is listed in the published scope.
Common questions about input validation
What is the difference between input validation and sanitization?
Input validation checks input against rules and rejects what fails; sanitization changes the input to make it safe. I prefer rejecting at the boundary and encoding on output. OWASP’s Input Validation Cheat Sheet says input validation “should not be used as the primary method of preventing XSS, SQL Injection and other attacks” but “can significantly contribute to reducing their impact if implemented properly.”
What are the best practices for input validation?
Put an allowlist schema on the server in front of every write endpoint, and let the handler use only what the schema returns. Take owner and role fields from the session, escape user text wherever a page renders it, and keep tests that must fail: wrong types, missing fields, fields the user may not set, oversized values.
What are the 5 validation checks?
BBC Bitesize lists them as the range check, length check, presence check, format check and type check. In a web app each one is a line of the endpoint’s schema: a minimum and maximum, a length bound, a required field, a format such as an email or a UUID, and a type.
Is Zod better than Joi?
Neither wins in general, because they solve the same problem. A Zod schema doubles as a TypeScript type through z.infer, while Joi calls itself a schema description language and data validator for JavaScript. I’d stay with whichever the project already has and start new TypeScript code on Zod.
Is Zod for frontend or backend?
Both. One Zod schema can run on the server and in the browser, as the UI validation section says. Only the server copy protects the data; the browser copy shows the error sooner.
If this checklist left you with more open items than you expected, the sprint below works through all of them in ten working days.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase