Learn · HTML
HTML form validation, client and server
Browsers ship a complete validation system: declarative rules in attributes, a JavaScript API to query and extend them, and CSS hooks to style the result. Used well, it removes most client-side validation code. It also has a few sharp edges, and none of it replaces checking the data on the server.
Declarative rules
required, type (email, url, number, date...), min/max/step, minlength/maxlength and pattern all feed one model. Each failing rule sets a flag on the element's validity object (valueMissing, typeMismatch, patternMismatch, tooShort, rangeOverflow, customError and so on), and the browser refuses to submit while any control is invalid.
patternmust match the whole value: it is implicitly wrapped in^(?:and)$.- An empty value passes
pattern,minlengthandtype="email". Addrequiredif the field must be filled. patternis compiled with thevflag, so inside a character class a literal-,(,),[,{,},/or|has to be escaped.[a-z0-9._%+-]is a syntax error invmode; write[a-z0-9._%+\-].- An invalid pattern is not applied at all. There is no error on the page; the field just accepts anything.
type="email"follows the HTML spec's own definition, which is a deliberate violation of RFC 5322. It acceptsa@b(no dot required) and rejects some addresses RFC 5322 allows.
The constraint validation API
checkValidity() returns a boolean and fires invalid events; reportValidity() does the same and shows the browser's messages. setCustomValidity(message) marks a control invalid with your text; the control stays invalid until you call it again with an empty string. That makes it the tool for rules attributes cannot express, such as a confirmation field:
js
const email = form.elements.email;
const confirm = form.elements.email_confirm;
confirm.addEventListener("input", () => {
confirm.setCustomValidity(
confirm.value === email.value ? "" : "The two addresses do not match.",
);
});novalidate with your own messages
novalidate on the form turns off the browser's submit-time check and bubbles, but the whole API keeps working. That is the clean way to render your own inline errors while still using the browser's rules:
html
<form id="signup" novalidate>
<label for="email">Work email</label>
<input id="email" name="email" type="email" required aria-describedby="email-error">
<p id="email-error" class="field-error" hidden></p>
<button>Sign up</button>
</form>
<script type="module">
const form = document.querySelector("#signup");
form.addEventListener("submit", (event) => {
let firstInvalid = null;
for (const el of form.elements) {
if (!el.willValidate) continue;
const out = document.getElementById(el.id + "-error");
const ok = el.validity.valid;
el.toggleAttribute("aria-invalid", !ok);
if (out) {
out.hidden = ok;
out.textContent = ok ? "" : el.validationMessage;
}
if (!ok && !firstInvalid) firstInvalid = el;
}
if (firstInvalid) {
event.preventDefault();
firstInvalid.focus();
}
});
</script>validationMessage is the browser's localized text; swap in your own per validity flag if you need a specific tone. For styling, :user-invalid (Baseline since November 2023) matches invalid fields only after the user has interacted with them or tried to submit, which avoids painting a fresh form red.
Why the server still validates
Everything above runs in a browser you do not control. curl, a bot, devtools or a browser with JavaScript off skips it. Repeat every rule that protects data quality or your systems on the server, and return the failing field so a fetch() client can show it:
js
const RULES = {
email: { required: true, max: 254, email: true },
message: { required: true, max: 5000 },
};
function validate(body) {
for (const [name, rule] of Object.entries(RULES)) {
const value = String(body[name] ?? "").replace(/\r\n/g, "\n").trim();
if (rule.required && !value) return { field: name, code: "required" };
if (value.length > rule.max) return { field: name, code: "too_long" };
if (rule.email && value && !/^[^\s@]+@[^\s@]+$/.test(value)) {
return { field: name, code: "invalid_email" };
}
}
return null;
}
// const error = validate(req.body);
// if (error) return res.status(422).json({ ok: false, ...error });Note the CRLF normalization. During submission the browser converts newlines in values to CRLF, while maxlength counts the LF-only value in the page. A textarea at its maxlength with line breaks arrives longer than the limit you set, so compare against the normalized value or leave headroom.
Patterns on the server: watch for ReDoS
If your server runs regular expressions that someone else wrote (a CMS field setting, a form builder, a per-customer rule), a backtracking engine like JavaScript's can be made to take exponential time. (a+)+$ against a long run of a followed by ! is the textbook case, and on a single-threaded server one request stalls everyone.
Mitigations, cheapest first: cap the input length before matching, reject patterns with nested quantifiers or backreferences when they are saved, anchor patterns so the engine cannot retry at every offset, or use a linear-time engine such as RE2 when you need arbitrary patterns.
How this works with Formward
On hosted forms and Pages built in the Formward dashboard, field rules (required, email, number range, pattern) are enforced server-side on every submission, with a 422 carrying field and code when one fails. Custom patterns go through a save-time ReDoS guard that rejects backreferences, lookbehind, nested repetition such as (a+)+, ambiguous repeated alternation, more than three unbounded quantifiers and patterns over 200 characters. Matching is anchored, and values over 1,000 characters fail a pattern rule without the regex running.
For your own HTML form posting to the endpoint, Formward applies global caps (150 fields, 50,000 characters per value) and leaves field rules to your markup. See hosted forms and responses.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.