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).

The browser checks; an HTTP client doesn'tTry it yourself
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 422 with 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.