Validate a form server-side
Check that the data received from a form is complete and coherent before storing it.
No AI needed
FreeInstantYour data never leaves
The verdict
N0 is not the first step of a staircase here, it is the only step that holds. A form rule is written before it is enforced: you can read it, unit test it, and show it to whoever disputes it. The three levels above would trade that property for a decision you can neither replay nor justify, when replaying it and justifying it is precisely what a validation is asked for.
Declarative schema, one error message per field
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Validate a form on the server: a declarative schema, one error per field.
Rung N0. Deterministic, standard library only. The rules stay readable, and
every refusal can be explained to the person who typed the form.
Four decisions carry the design.
First, the schema is data, not code. It can be written next to the form, read by
someone who does not write Python, and handed to the JavaScript version that
guards the same form in the browser. A pattern means the same thing on both
sides only with explicit classes: the shorthands for a digit and a word
character reach beyond ASCII in Python and stop at it in JavaScript, so write
[0-9] and [A-Za-z].
Second, the answer is a mapping of field name to message, never a boolean. A
form that answers "no" without saying which field is wrong sends the user
hunting, and sends the developer to the logs.
Third, one message per field: checks stop at the first broken rule. Telling
someone their password is too short *and* badly formed at once is noise; fix
the first thing, resubmit, see the next.
Fourth, leading and trailing whitespace comes off every string before anything
is checked, and a string left empty by that is treated as absent. A browser
sends what the user typed, and a phone's autofill adds a space: without this,
" ada@example.com" is refused by a pattern nobody can see the fault in, and a
required field filled with two spaces passes as filled. The HTML standard
trims the value of an email field for the same reason.
One thing the schema has to carry, and this file cannot: a maximum length on
every string. A field without `max` accepts megabytes, and the pattern is then
applied to megabytes.
"""
import re
import unicodedata
# The types a form field can hold once decoded. `bool` is excluded from
# `integer` on purpose: in Python a boolean *is* an int, and a checkbox is not
# an age. 36.0 is an integer, as it is in JavaScript.
TYPES = {
"string": lambda v: isinstance(v, str),
"integer": lambda v: not isinstance(v, bool)
and (isinstance(v, int) or (isinstance(v, float) and v.is_integer())),
}
def trimmed(value):
"""A string without its edge whitespace; anything else is left alone."""
return value.strip() if isinstance(value, str) else value
def check(value, rule):
"""Return the first broken rule as a message, or None if the value passes."""
kind = rule.get("type", "string")
if not TYPES[kind](value):
return f"must be of type {kind}"
# For a string the bounds read as a length; for a number, as a value. The
# length counts code points once composed (NFC), as the JavaScript version
# does: "é" counts one however it was typed, and an emoji made of several
# code points, such as a flag, still counts several.
text = unicodedata.normalize("NFC", str(value))
size, unit = (len(text), " characters") if kind == "string" else (value, "")
if "min" in rule and size < rule["min"]:
return f"must be at least {rule['min']}{unit}"
if "max" in rule and size > rule["max"]:
return f"must be at most {rule['max']}{unit}"
if "pattern" in rule and not re.fullmatch(rule["pattern"], text):
return rule.get("message", "is not in the expected format")
return None
def validate(data, schema):
"""
Check a submitted form against a schema, and return {field: message}.
An empty mapping means the form is valid. A missing key, an explicit None,
an empty string and a string of spaces are the same thing here, because
that is what a browser posts for a field the user left alone.
"""
# A field the schema does not declare is refused, not ignored: whoever
# stores the submission would otherwise store it too.
errors = {field: "is not a field of this form" for field in data if field not in schema}
for field, rule in schema.items():
value = trimmed(data.get(field))
if value is None or value == "":
if rule.get("required"):
errors[field] = "is required"
continue
message = check(value, rule)
if message is not None:
errors[field] = message
return errorsJavaScript
/**
* Validate a form on the server: a declarative schema, one error per field.
*
* Rung N0. Deterministic, no dependency. The rules stay readable, and every
* refusal can be explained to the person who typed the form.
*
* Four decisions carry the design.
*
* First, the schema is data, not code. It can be written next to the form, read
* by someone who does not write JavaScript, and handed to the Python version
* that guards the same form on the other side. A pattern means the same thing
* on both sides only with explicit classes: the shorthands for a digit and a
* word character reach beyond ASCII in Python and stop at it in JavaScript, so
* write [0-9] and [A-Za-z].
*
* Second, the answer is a mapping of field name to message, never a boolean. A
* form that answers "no" without saying which field is wrong sends the user
* hunting, and sends the developer to the logs.
*
* Third, one message per field: checks stop at the first broken rule. Telling
* someone their password is too short *and* badly formed at once is noise; fix
* the first thing, resubmit, see the next.
*
* Fourth, leading and trailing whitespace comes off every string before
* anything is checked, and a string left empty by that is treated as absent. A
* browser sends what the user typed, and a phone's autofill adds a space:
* without this, " ada@example.com" is refused by a pattern nobody can see the
* fault in, and a required field filled with two spaces passes as filled. The
* HTML standard trims the value of an email field for the same reason.
*
* One thing the schema has to carry, and this file cannot: a maximum length on
* every string. A field without `max` accepts megabytes, and the pattern is
* then applied to megabytes.
*/
// The types a form field can hold once decoded. `Number.isInteger` rejects
// NaN, Infinity and 1.5 in one go, which is what a form needs.
const TYPES = {
string: (v) => typeof v === 'string',
integer: (v) => Number.isInteger(v),
};
/** A string without its edge whitespace; anything else is left alone. */
export function trimmed(value) {
return typeof value === 'string' ? value.trim() : value;
}
/** Return the first broken rule as a message, or null if the value passes. */
export function check(value, rule) {
const kind = rule.type ?? 'string';
if (!TYPES[kind](value)) return `must be of type ${kind}`;
// For a string the bounds read as a length; for a number, as a value.
// The length counts code points once composed (NFC), as the Python version
// does: "é" counts one however it was typed, and an emoji made of several
// code points, such as a flag, still counts several.
const text = String(value).normalize('NFC');
const [size, unit] = kind === 'string' ? [[...text].length, ' characters'] : [value, ''];
if (rule.min !== undefined && size < rule.min) return `must be at least ${rule.min}${unit}`;
if (rule.max !== undefined && size > rule.max) return `must be at most ${rule.max}${unit}`;
// Anchored, so a pattern matches the whole field and not a fragment of it.
if (rule.pattern !== undefined && !new RegExp(`^(?:${rule.pattern})$`).test(text)) {
return rule.message ?? 'is not in the expected format';
}
return null;
}
/**
* Check a submitted form against a schema, and return {field: message}.
*
* An empty object means the form is valid. A missing key, an explicit null, an
* empty string and a string of spaces are the same thing here, because that is
* what a browser posts for a field the user left alone.
*/
export function validate(data, schema) {
// A field the schema does not declare is refused, not ignored: whoever
// stores the submission would otherwise store it too.
const errors = {};
for (const field of Object.keys(data)) {
if (!Object.hasOwn(schema, field)) errors[field] = 'is not a field of this form';
}
for (const [field, rule] of Object.entries(schema)) {
const value = trimmed(data[field]);
if (value === undefined || value === null || value === '') {
if (rule.required) errors[field] = 'is required';
continue;
}
const message = check(value, rule);
if (message !== null) errors[field] = message;
}
return errors;
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
One line per field. The messages in the third column are the ones the snippet produces itself.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
nothing matched in this text
Given
email: marie.durand@exemple.fr display_name: Marie Durand age: 34 website: https://menuiserie-dubois.fr
What it returns
Form accepted
No rule of the schema is broken.
In detail
| Field | Submitted value | Server’s answer |
|---|---|---|
| marie.durand@exemple.fr | accepted | |
| display_name | Marie Durand | accepted |
| age | 34 | accepted |
| website | https://menuiserie-dubois.fr | accepted |
Here it breaks
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the submission never leaves your infrastructure
- Messages name the field and the rule it broke, never the value typed: what ends up in your error logs stays what you put there, except that an undeclared field is named as it was sent
Breaking point
No rule can tell whether what was typed exists, or whether it means anything. The test submits `ada@no-such-mailbox.example`: the shape is right, the schema accepts it, and nobody reads mail sent there. It also submits a display name made of two zero-width characters: edge whitespace comes off before the checks, but U+200B is not whitespace, `min` counts it as a character, and the profile shows up blank. Finding out takes an external check — a confirmation message, a lookup on the domain — which is a different need.
When to move up a level
Well-formed but false entries start costing you something you can see: accounts created that never confirm, mail that bounces. No level above answers that; what you need is an external check, which is a different need.
The four options
- RecommendedN0Declarative schema, one error message per fieldFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn. A minimum age, a display name length, an accepted address format: these are written decisions you can be held to, not regularities waiting in data. A classifier trained on past submissions would learn what was accepted yesterday, mistakes included, and still could not name the field to fix.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same reason as N1, plus a permanent service to run. A self-hosted model does not make a rule any more correct: it makes the verdict costlier to obtain, slower to return, and impossible to read back in the schema the team maintains.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
A validation has to refuse deterministically, and to justify the refusal to the person whose entry it refuses. A model that sometimes accepts and sometimes refuses the same submission validates nothing: it offers an opinion. Two identical submissions must get the same answer, and that answer must be readable in a rule you can produce — a schema, a line of code, a test — not in a sentence whose production you can neither replay nor ground. On top of that, a form carries text written by the very person being validated: handing it to a model that reads text as instruction lets them lean on the verdict that concerns them.
Further reading
- JSON Schema — Validation, le vocabulaire d’un schéma déclaratif
- OWASP — Input Validation Cheat Sheet
- OWASP — LLM01 Prompt Injection, une saisie qui modifie la conduite du modèle qui la lit
- WHATWG HTML — définition normative d’une adresse électronique valide
- MDN — Validation de formulaire, et pourquoi le contrôle côté client ne suffit pas
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.