Modern HTML5 in Production · Forms That Work
Client-Side vs Server-Side Validation
Why a 'required' quantity field didn't stop negative orders, and how HTML validation and server validation work together.
Scenario
Example app: an order form uses HTML validation: <input type="number" name="qty" min="1" max="10" required>. The backend trusts the values because "the form doesn't allow invalid input".
What you see in production
- Orders with quantity -5 appear, producing negative totals and refunds to customers' cards.
- Logs show the requests came from scripts, not from the form.
- Another order has quantity
1e9, which overflowed an inventory counter.
The naive code
<input type="number" name="qty" min="1" max="10" required>
qty = int(request.form["qty"]) # trusted because the browser "validated" it
charge(customer, price * qty)
Reproduce it
The simulation sends the same order through a browser (which applies the HTML rules) and directly with an HTTP client (which doesn't).
const rules = { qty: { required: true, min: 1, max: 10, integer: true } };
function browserAllowsSubmit(form) {
const v = form.qty;
const n = Number(v);
return v !== "" && Number.isFinite(n) && n >= rules.qty.min && n <= rules.qty.max;
}
function serverValidate(body) {
const n = Number(body.qty);
if (!Number.isInteger(n) || n < 1 || n > 10) return "422 qty must be an integer from 1 to 10";
return `201 order created for ${n} item(s)`;
}
function naiveServer(body) {
return `201 order created for ${Number(body.qty)} item(s), total ${12 * Number(body.qty)} USD`;
}
const attempts = [
{ via: "browser form", body: { qty: "-5" } },
{ via: "curl / script", body: { qty: "-5" } },
{ via: "curl / script", body: { qty: "1e9" } },
{ via: "browser form", body: { qty: "3" } },
];
for (const { via, body } of attempts) {
if (via === "browser form" && !browserAllowsSubmit(body)) {
console.log(`${via.padEnd(14)} qty=${body.qty.padEnd(4)} -> blocked by the browser`);
continue;
}
console.log(`${via.padEnd(14)} qty=${body.qty.padEnd(4)} -> naive server: ${naiveServer(body)} | validating server: ${serverValidate(body)}`);
}
Output
browser form qty=-5 -> blocked by the browser curl / script qty=-5 -> naive server: 201 order created for -5 item(s), total -60 USD | validating server: 422 qty must be an integer from 1 to 10 curl / script qty=1e9 -> naive server: 201 order created for 1000000000 item(s), total 12000000000 USD | validating server: 422 qty must be an integer from 1 to 10 browser form qty=3 -> naive server: 201 order created for 3 item(s), total 36 USD | validating server: 201 order created for 3 item(s)
Root cause
HTML validation runs in the user's browser, which the user controls. Anyone can submit requests without it: from DevTools, with curl, with a script, or with a modified page. It's a usability feature, not a security boundary.
The fix
- Keep HTML validation (
required,type,min,max,pattern,maxlength) for fast, friendly feedback. - Validate everything again on the server with the same rules: types, ranges, lengths, allowed values, and business rules the browser can't know (stock levels, permissions, prices).
- Never trust prices, totals or IDs from the form; recompute them on the server from trusted data.
- Return field-level errors (for example
422with a list of problems) so the form can show them next to the fields. - Share the rules where you can (a schema used by both, such as JSON Schema, Zod or Pydantic) to keep client and server in sync.
Trade-offs
- Duplicated rules can drift; a shared schema or server-driven form definitions reduce that.
- Some checks need the server anyway (is the username taken?); call them asynchronously from the form for early feedback.
Detect it in production
- Log and alert on server-side validation failures that the client should have prevented: they indicate bugs or abuse.
- Database constraints (
CHECK (qty > 0)) are a last line of defence.
Key points
- HTML validation is UX; server validation is security and correctness.
- Validate every field on the server, and recompute prices and totals from trusted data.
- Return structured field errors so the form can display them.
Practice questions
If a form uses required, min and max attributes, why must the server validate again?
Answer
Client-side validation runs in the user's browser and can be bypassed with DevTools, scripts or direct HTTP requests. Only the server can enforce rules reliably.
Which values from a form should the server never trust?
Answer
Prices, totals, discounts, user or account IDs, roles and anything that affects money or permissions. Recompute or look them up from trusted server-side data.
How can you keep client and server validation rules in sync?
Answer
Define them once in a shared schema (JSON Schema, Zod, Pydantic models exported to the client) or render forms from server-side definitions, and test both paths.
Open in the app to mark lessons complete, take notes and track your progress.