---
name: "form_validation"
description: "Validate forms on both sides: HTML5 constraint attributes, Constraint Validation API for inline accessible errors, submit guards against double-submits, server-side re-validation, and security gotchas. Triggers on: form validation, validate a form, inline form errors, constraint validation, server-side validation, form error UX, accessible form errors, duplicate submit."
metadata: { "includeInPrompt": true }
---

## Purpose

Practical validation for forms an agent **builds or consumes**: declarative client-side checks with accessible error UX, submit guards that stop double-submits, and a server side that re-validates everything from scratch — plus the security gotchas that live specifically in the form layer. This skill is the form layer only: authentication flows belong to `api-auth-patterns`, and durable duplicate handling via idempotency keys is covered by `webhook-handling` (cross-referenced below).

## Workflow

1. **Start declarative — use HTML5 constraint attributes first.** They give you validation, mobile keyboards, and basic accessibility for free before you write any JS:

```html
<form id="signup" method="post" action="/signup" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required maxlength="254"
         aria-describedby="email-error">
  <p id="email-error" class="error" aria-live="polite"></p>

  <label for="handle">Handle</label>
  <input id="handle" name="handle" type="text" required
         minlength="3" maxlength="24" pattern="[A-Za-z0-9_]+"
         aria-describedby="handle-error">
  <p id="handle-error" class="error" aria-live="polite"></p>

  <label for="age">Age</label>
  <input id="age" name="age" type="number" min="13" max="120" required
         aria-describedby="age-error">
  <p id="age-error" class="error" aria-live="polite"></p>
</form>
```

Rules of thumb: `type="email"` over hand-rolled email regexes; `maxlength` on every text input (it is also a cheap injection/DOS bound); `pattern` for simple formats only (handles, slugs, postal codes); `min`/`max`/`step` for numbers; `required` where the server also requires it. Keep attribute rules and server rules identical — drift between them is where bugs live. (`novalidate` is there because step 2 replaces native bubbles with inline errors; drop it if you want the browser's default UI instead.)

2. **Render inline errors accessibly with the Constraint Validation API.** Real APIs used here: `setCustomValidity()`, `checkValidity()`, `reportValidity()`, `validationMessage`, and the `validity` object. Pattern: validate on blur (per field) and on submit (whole form); clear custom errors before re-checking; point each field at its error container with `aria-describedby` and flip `aria-invalid`:

```js
const form = document.getElementById("signup");

function validateField(field) {
  const err = document.getElementById(field.getAttribute("aria-describedby"));
  field.setCustomValidity(""); // clear any previous custom error first
  if (field.id === "handle" && field.value.includes("admin")) {
    field.setCustomValidity("That handle is reserved.");
  }
  const ok = field.checkValidity();
  field.setAttribute("aria-invalid", String(!ok));
  if (err) err.textContent = ok ? "" : field.validationMessage;
  return ok;
}

form.addEventListener("focusout", (e) => {
  if (e.target.willValidate) validateField(e.target);
});

form.addEventListener("submit", (e) => {
  let firstBad = null;
  for (const field of form.elements) {
    if (field.willValidate && !validateField(field) && !firstBad) firstBad = field;
  }
  if (firstBad) { e.preventDefault(); firstBad.focus(); }
});
```

Why this shape: `aria-live="polite"` on the error container makes screen readers announce the message; moving focus to the first invalid field on submit keeps keyboard users oriented; validating on blur avoids punishing users while they type. If you prefer native bubbles instead, drop `novalidate` and call `form.reportValidity()` on submit — but never rely on native UI alone for anything complex.

3. **Guard the submit against double-clicks and retries.** Disable the submit button (and show a busy state) the moment the form passes client validation, re-enabling only on failure. Client-side guards are UX, not correctness: the server must still dedupe. For anything with side effects (payments, signups, ticket creation), send an idempotency key with the submission and dedupe server-side — the same two-layer pattern as `webhook-handling` (unique key constraint + idempotent handler).

4. **Re-validate everything on the server. Never trust the client.** Client validation is a courtesy; the server treats the body as hostile input. Checklist per field (language-agnostic):

   - **Presence:** required fields checked server-side; missing != empty (reject or default explicitly, don't let `undefined` slip through).
   - **Type and coercion:** parse numbers/dates/booleans explicitly; reject values that don't parse instead of coercing silently (`"abc"` as age must fail, not become `0`).
   - **Length bounds:** enforce the same `minlength`/`maxlength` as the client — a missing server-side max is a storage/DOS vector.
   - **Format:** prefer allowlists over blocklists. Reuse the same character sets as the client `pattern`; for emails, a simple `local@domain` shape check plus a confirmation/send-to-verify step beats a perfect regex.
   - **Enums and selects:** never trust that a submitted value came from your `<select>` or radio group. Re-check membership in the allowed set (`plan in {"free","pro","team"}`) — option lists are trivially forged.
   - **Cross-field rules:** "password matches confirmation", "end date after start date" — these only exist server-side.
   - **Normalize before validating:** trim whitespace, normalize unicode (NFC) on identifiers, then validate. Validate-then-normalize lets `"admin "`-style edge cases through.

   Return errors keyed by field name (`{"handle": "That handle is reserved."}`) so the client can map them back to the same inline error containers from step 2.

5. **Handle the form-layer security gotchas.**
   - **Error messages leak information.** On login forms, use one generic message ("Invalid email or password") — separate "user not found" vs "wrong password" messages let attackers enumerate accounts. Same for password reset ("If an account exists, we've emailed it").
   - **Never echo unsanitized input in errors.** An error page that reflects `"User <script>…" not found` is reflected XSS. Escape or drop the value.
   - **URL fields are SSRF-adjacent.** If the server will ever fetch a submitted URL (avatar, webhook URL, link preview), validate scheme (`https` only), reject credentials in the URL (`user:pass@host`), localhost/private IPs, and redirects — treat it as an outbound-request problem, not just a format check.
   - **Sensitive values don't belong in error payloads.** Validation responses shouldn't include the submitted password, tokens, or full card numbers — not in the message, not in a debug dump.
   - **Login forms get rate-limited by the backend** (brute-force protection). That's a server/infrastructure concern, not something to hand-roll in the form code — if you're consuming a login API, respect its `429` responses with backoff instead of retrying blindly.

## Output Contract

After applying this skill, an agent should be able to produce:

- A form whose inputs carry matching declarative constraints (`required`, `type`, `minlength`/`maxlength`, `pattern`, `min`/`max`) and whose errors render inline: `aria-describedby`-linked containers, `aria-live` announcements, `aria-invalid` toggling, blur + submit validation via the Constraint Validation API, focus moved to the first invalid field.
- A submit guard (button disable + busy state) plus a server-side idempotency plan for side-effecting submissions.
- A server-side validator covering presence, type/coercion, length, format, enum allowlists, cross-field rules, and normalize-before-validate — returning field-keyed errors the client can map back to the inline containers.
- Error copy that doesn't enumerate accounts, reflect unsanitized input, or leak sensitive values; URL fields handled as outbound-request risk.

## Operating Rules

- **Client validation is UX; server validation is security.** Ship both, rely only on the second.
- **Keep the two rule sets identical.** Client constraints and server checks must encode the same lengths, formats, and requiredness — drift is a bug source.
- **Allowlist, don't blocklist.** Enum values, character sets, and URL schemes are checked against what is permitted, not against a list of known-bad values.
- **One generic auth error.** Login and reset flows never distinguish "unknown user" from "wrong credential" in their messages.
- **Don't reflect input in errors.** Any value from the request that appears in an error response is escaped or omitted.
- **Dedupe where it counts.** Side-effecting forms get an idempotency key and a server-side dedupe (see `webhook-handling`), not just a disabled button.
- **Rate-limiting lives server-side.** Don't invent client-side throttling as brute-force protection; on the consuming side, back off on `429`.
