Skip to main content
ASoc
Tutorial

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.

The ASoc Team10 min read

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 FormDataslotId, 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 redeemSlot server action. Reads EXACTLY slotId / productSlug / framework off the FormData — any other field present (a spoofed user_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_name allowlist: 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

SymptomCauseFix
"React JS validation" passes in the browser, bad data still reaches the databaseValidation only runs client-side; the server trusts whatever arrivesRe-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 anywayServer code parses FormData generically (e.g. Object.fromEntries) and passes the whole object to a writeRead 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 attackerA granular reason (invalid_slot_id vs invalid_product_slug) is surfaced verbatim to the client on a security-sensitive actionFine 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 namesPattern assumes ASCII letters onlyUse 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 disagreeingConstraint changed in a migration without updating the matching regex, or vice versaTreat 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.

Keep reading

Tutorial9 min read

React Hooks: Why This Codebase's `useContext` Count Is Zero

24 useState, 13 useEffect, 0 useContext across 22 files — a real hook census, and the module-scope store pattern this codebase uses instead of Context.

Read more
Tutorial8 min read

Skipping React Hook Form: What Four Real Forms Look Like Without One

Login, signup, password reset and contact all run on FormData, a Server Action and useActionState — zero form libraries in package.json. Here's the actual validation code.

Read more
Tutorial10 min read

React Landing Pages: Half the HTML Isn't the Page

This storefront's home page prerenders to 436 KB, and 52.1% of that is a serialized copy of the render, not the page. What actually reaches a crawler before any JavaScript runs.

Read more