Elementary Standards
HTMLCSSWeb StandardsSEOBlogStart reading
Home / Blog / Why HTML5 Form Validation Isn't Enough
HTML

Why HTML5 Form Validation Isn't Enough

The Constraint Validation API makes forms feel solid in the browser — but it was never designed to protect your data, and treating it as a security boundary is a mistake that shows up later, in production.

TL;DR: HTML5's required, pattern, and the Constraint Validation API give you fast, accessible client-side feedback — but they only check syntax, run entirely in a browser you don't control, and can be disabled or bypassed in seconds. Treat them as UX, not security, and validate everything again on the server.

It's an easy mistake to make, because the symptom and the fix look identical from the front end. A form with required fields won't submit until they're filled in, so it feels like the data reaching your backend has already been checked. What actually happened is narrower: the browser confirmed the string in the box matches a pattern — it said nothing about whether that string is safe to store, unique in your database, or permitted for the user currently logged in.

That boundary — between what the browser is willing to accept and what your business logic actually allows — is where the real engineering happens. Getting it right means treating every client payload as untrusted, re-validating business rules on the server, and keeping both layers in sync without copying the same regular expression into two languages. It's exactly the kind of plumbing a custom software development team gets right the first time, instead of patching it in after the first bad submission makes it to production.

fieldBrowsercheckServercheckskippable in DevToolsthe only real gate

What the Constraint Validation API actually checks

The Constraint Validation API is the machinery behind HTML5's built-in form checks — the part of the spec that decides whether a field is "valid" before the form submits. It reads a handful of attributes straight off the markup:

<input type="email" required>
<input type="text" pattern="[A-Za-z]{2,30}" required>
<input type="number" min="18" max="120">
<input type="url">

required checks presence, pattern checks shape against a regular expression, min/max check a numeric or date range, and the type attribute itself (email, url, number) carries an implicit format check the browser runs for you. Every one of these is a syntactic check — does the string look like the kind of thing it claims to be. None of them is a semantic check — is this a real address that receives mail, is this username actually available, does this user have permission to submit this value. MDN's guide to client-side form validation walks through the full attribute set if you need the reference.

Where the browser's checks stop

Open DevTools, select the input, and delete the required attribute. The validation is gone — no exploit, no code change, two clicks. A user with JavaScript disabled never ran your custom validators in the first place, only whatever the browser enforces natively. And anyone talking to your API directly — a script, a second client, curl — never touches the DOM at all, so the Constraint Validation API never runs for them. Native validation is a convenience layer for people using your form the way you expected. It is not a gate.

Why server-side validation isn't optional

Client-side checks are UX: they catch typos the instant they happen, before a round trip to the server, so a user fixes a malformed email address in place of waiting for a page reload. Server-side checks are trust: they're the only validation a request can't opt out of, because they run on hardware you control, after the request has already arrived. The same rule usually needs to exist in both places — once in the markup for instant feedback, once in application logic for enforcement — and the two need to stay in sync as the rule changes.

<!-- Client: instant feedback, skippable -->
<input type="email" required>

// Server: the actual gate — runs no matter what sent the request
if (!isValidEmail(req.body.email)) {
  return res.status(422).json({ error: "invalid email" });
}

Styling validation states without re-inventing the API

CSS has its own hooks into the same validity state the Constraint Validation API computes, so you don't need JavaScript just to color a field red. :valid and :invalid match based on the field's current ValidityState — but both match immediately, even before the user has typed anything, which is why an empty required field looks "invalid" the moment the page loads. :user-invalid fixes that: it only matches after the user has interacted with the field and left it in an invalid state.

input:user-invalid {
  border-color: #c23b3b;
}
input:valid {
  border-color: #3f6b04;
}

Reach for :user-invalid as the default for error styling. Reserve plain :invalid for cases where showing the state immediately is actually useful — a password-confirmation field that should flag a mismatch the moment it's typed, for example.

Do your error messages actually reach assistive tech?

Associating errors with fields via aria-describedby and aria-invalid

The browser's native validation bubble is inconsistent across browsers and assistive technology — don't rely on it alone. Tie a custom error message to its field explicitly:

<label for="email">Email</label>
<input type="email" id="email" aria-describedby="email-err" aria-invalid="true">
<p id="email-err">Enter an address in the form [email protected].</p>

aria-describedby makes a screen reader announce the error text alongside the label when focus lands on the field; aria-invalid="true" flags the field's state directly. Clear both when the field becomes valid — a stale aria-invalid="true" on a field the user already fixed is its own kind of bug.

Why aria-live="polite" matters for dynamically-inserted errors

If an error message is inserted into the DOM after the page has already loaded — on blur, on submit, after an async check — a screen reader won't announce it unless something tells it to look. A live region does that:

<div aria-live="polite" id="form-errors"></div>

polite waits for the current speech to finish before announcing the update, instead of interrupting mid-sentence the way assertive would. For form errors, that's almost always the right choice — a user still typing shouldn't be cut off to be told about a field they haven't reached yet.

Real-time or on-submit: which should you pick?

It depends on what the check costs. A format check — does this look like an email address, is this field non-empty — is free to run on every keystroke, so validate it in real time and let the user fix it before they even reach submit. A check that needs a network round trip — is this username already taken, does this coupon code exist — is expensive and often wrong mid-typing, so defer it to blur or submit, after the user has stopped typing into that field. Validating an empty field the instant the form loads, before the user has touched it, is the most common version of this mistake — it's what :user-invalid exists to prevent at the CSS layer, and the same logic applies to any JavaScript validator you write by hand.

Client-side vs. server-side: who's responsible for what

CheckClient-sideServer-side
Format checks (email shape, number range)Owns it — instant feedbackRe-checks it — never trusts the client
Required fieldsBlocks submissionRejects the request if missing
Business rules (uniqueness, permissions)Can't own it — no access to the dataOwns it entirely
Data sanitizationNot a safety boundaryOwns it — escaping, parameterized queries
Security enforcementBypassable in secondsThe only enforcement that counts
User feedback speedOwns it — no round tripToo slow for keystroke-level feedback

FAQ

Is HTML5 validation enough for a production form?

No. It's a UX layer that catches typos before submission, but it can be disabled or bypassed entirely. Pair it with server-side validation that runs regardless of what sent the request.

Can users bypass HTML5 form validation?

Yes — by removing the required attribute in DevTools, disabling JavaScript, or calling the underlying API directly without ever rendering the form.

Should I use the pattern attribute or a JavaScript regex?

Use pattern for simple format checks that need no custom error text. Reach for JavaScript and the Constraint Validation API when you need a custom error message, cross-field logic, or an async check.

What's the difference between :invalid and :user-invalid?

:invalid matches immediately on load if the field is empty and required. :user-invalid only matches after the user has interacted with the field, which avoids showing an error before they've had a chance to type anything.

Do screen readers announce HTML5's built-in validation bubbles?

Inconsistently, across different browser and assistive-technology combinations. Don't rely on the native bubble alone — add an explicit aria-describedby error message tied to the field.

Is novalidate ever the right call?

Yes — when you're fully replacing native validation with a custom JavaScript or Constraint Validation API flow and don't want the browser's default checks fighting your own.

Does the Constraint Validation API work inside Web Components?

Yes, via ElementInternals and attachInternals(), which let a custom element participate in form validation and submission like a native input — check browser support against your target list before relying on it. The WHATWG spec's constraints section is the authoritative reference for exactly which checks the API runs and when.

Back to the blog
More notes

Keep reading