React Form Validation: The Allowlist Is the Validation
Client-side checks are UX. The server-side function that reads three named fields and ignores everything else — proven by a test that smuggles in a fake user_id — is the actual gate.
Client-side validation in React is UX, not security — every check that runs in the browser can be skipped by anyone who opens dev tools or calls the endpoint directly. The validation that actually matters runs on the server, on the raw FormData, and it has to do more than check a field is present: it has to decide which fields it's willing to read at all. This codebase has a real example of getting that second part right, and a test suite that proves it.
The React Hook Form post already covers the client-side half of this decision — why this codebase has zero form libraries and validates through a Server Action instead of a resolver config. This post starts after that decision is made and covers only what the server does once the request arrives.
The allowlist, not the schema
Most "add validation to a React form" tutorials show a schema — required, min length, matches a pattern — and stop once the fields that are present pass. That misses a second question a server-side handler has to answer: what happens to fields that shouldn't be there at all?
src/lib/redemptionValidation.ts answers it explicitly. It backs redeemSlot, the Server Action a buyer calls to redeem an owned entitlement slot into a chosen catalog product:
// src/lib/redemptionValidation.ts
export function validateRedemptionInput(
formData: FormData,
): ValidateRedemptionResult {
const slotId = String(formData.get("slotId") ?? "");
const productSlug = String(formData.get("productSlug") ?? "");
const frameworkRaw = formData.get("framework");
if (!UUID_RE.test(slotId)) {
return { ok: false, reason: "invalid_slot_id" };
}
if (!SLUG_RE.test(productSlug) || productSlug.length > 64) {
return { ok: false, reason: "invalid_product_slug" };
}
// ...framework, if present, checked against a fixed allowlist
return { ok: true, value: { slotId, productSlug, framework } };
}
The function reads exactly three named fields off the FormData — slotId, productSlug, framework — and nothing else. Any other field a caller includes is simply never looked up. That's not an oversight; it's the whole design, and the comment above the function says so:
Pure allowlist (R-4) for the
redeemSlotserver action. Reads EXACTLYslotId/productSlug/frameworkoff the FormData — any other field present (a spoofeduser_id,status,redeemed_at, ...) is simply never read, so mass assignment via extra form fields has no effect.
Proving the allowlist, not just describing it
The test suite has a case built specifically to demonstrate this, not just to check the happy path:
// src/lib/__tests__/redemption-validation.test.ts
it("ignores extra fields — mass-assignment allowlist (R-4)", () => {
const form = fd({ slotId: VALID_SLOT_ID, productSlug: "asoc-admin" });
form.set("user_id", "someone-elses-id");
form.set("status", "revoked");
form.set("redeemed_at", "2020-01-01");
expect(validateRedemptionInput(form)).toEqual({
ok: true,
value: { slotId: VALID_SLOT_ID, productSlug: "asoc-admin" },
});
});
A form submission that smuggles in user_id, status, and redeemed_at still validates as ok: true — and the returned value contains none of those three fields, because the function never read them. That matters because a naive server-side "validator" pattern — spread the parsed FormData into an object, then check the object's shape — would pass all three straight through if the shape check happened to be loose, and a downstream .update() call built from that object would let a client overwrite columns it should never touch. Reading three named fields by name, rather than accepting whatever arrives, closes that off structurally: there's no code path where an extra field reaches the database, because there's no code path where it's ever assigned to a variable.
Seven tests cover the function in total — the accept cases (with and without an optional framework), three reject cases (missing, malformed, or out-of-allowlist values), and the mass-assignment case above. None of them touch a database or a session; the function takes FormData and returns a plain result, so the whole validation contract is testable without mocking Supabase.
What "invalid" means at the regex level
The other half of allowlisting a field is deciding what a valid value for it looks like, which is where most tutorials reach for "not empty" and stop:
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
slotId has to look like a UUID before it's ever used in a database lookup, and productSlug has to look like this catalog's actual slug shape (lowercase, hyphen-separated, capped at 64 characters) before it's passed to getProduct(). Neither check is about user experience — a malformed slug was never going to render a friendly error either way — they're about never handing an untrusted string to a query or a lookup function without first constraining its shape. src/lib/validation.ts has the same idea applied to a profile field:
// src/lib/validation.ts
export const DISPLAY_NAME_RE = /^[\p{L}\p{N} _'.-]{1,40}$/u;
The comment above it explains why this one is layered on top of a database constraint rather than replacing it:
R-17
display_nameallowlist: Unicode letters/numbers, space, and_ ' . -, 1–40 chars. No HTML metacharacter can ever match, so a stored name is inert even if a future sink forgets to escape it. Mirrors the DB CHECK constraint (supabase/migrations/0002_display_name_allowlist.sql) as app-layer defense-in-depth — this is the check that runs first.
Two independent checks — one in application code, one in the schema — agreeing on the same rule. If a future code path bypasses the application check, the database still refuses the row; if the constraint were ever loosened, the application check still runs first and rejects it before it reaches SQL. Neither one is "the" validation; the point is that either one failing stops the write.
What client-side validation is still for
None of this means skip the browser check. A real form still validates on blur or on submit so a user sees "please enter a valid email" before a round trip, and EMAIL_RE — the one shared regex the React Hook Form post already covers — runs in exactly one place either way. The point this post adds is what happens after the request lands: client-side feedback is a courtesy for a cooperative browser; the allowlist above is the actual boundary, because it runs unconditionally, on every caller, cooperative or not.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "React JS validation" passes in the browser, bad data still reaches the database | Validation only runs client-side; the server trusts whatever arrives | Re-run the same (or stricter) checks server-side — client validation is UX, not a gate |
| A form field a client shouldn't be able to set ends up changed anyway | Server code parses FormData generically (e.g. Object.fromEntries) and passes the whole object to a write | Read named fields explicitly, one by one, the way validateRedemptionInput does — never forward an unshaped object to a mutation |
| "Validation in React JS" error messages leak which check failed in a way that helps an attacker | A granular reason (invalid_slot_id vs invalid_product_slug) is surfaced verbatim to the client on a security-sensitive action | Fine for a general form; on anything auth- or ownership-adjacent, collapse distinct failure reasons into one generic message before it reaches the UI |
| A regex-based check "works" in testing but rejects valid international names | Pattern assumes ASCII letters only | Use a Unicode-aware class (\p{L}, with the u flag) the way DISPLAY_NAME_RE does, rather than [a-zA-Z] |
| Two validation layers (app + database) drift and start disagreeing | Constraint changed in a migration without updating the matching regex, or vice versa | Treat the pair as one rule in two places — update both in the same commit, the way DISPLAY_NAME_RE and its migration are meant to move together |
FAQ
What's the best way to do form validation in React JS? Validate in the browser for immediate feedback, and validate again on the server as the actual gate — the same rule, checked twice, for two different reasons (UX and security). Neither replaces the other.
Should I use a schema library like Zod for this?
This codebase doesn't — zero validation libraries appear in package.json, matching the "zero form libraries" finding the React Hook Form post measured. A schema library is a reasonable choice for larger or more numerous forms; the allowlist principle above (read named fields, reject the rest) applies whether you write it by hand or generate it from a schema.
Is checking formData.get("field") enough, or do I need a full parsing library?
It's enough as long as every field you read is validated for shape before use, and every field you don't explicitly read is never assigned anywhere. The library doesn't make that guarantee — the code path does.
How is this different from just checking required on the <input>?
The HTML required attribute is client-side and cooperative — it stops an accidental empty submit through the browser's own UI. It does nothing against a request built by hand, which is exactly the case server-side validation exists to cover.
Templates in this post
ASoc Nexus is a SaaS marketing site with a dashboard mock and an app-download section — the kind of product whose signup or demo-request form is a direct match for the allowlist pattern above. ASoc Nimbus is a cloud-software site with tiered pricing, where a plan-selection form benefits from the same "read named fields, reject the rest" discipline. ASoc Nova is a crypto-trading landing page with live price widgets, where a waitlist or alert-signup form is the same shape again.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the client-side architecture decision this post assumes, see React Hook Form; for the Server Action mechanics around a form submission, Server Actions vs. API Routes.
