Skip to content
OSOKORO

Validating form answers in the database, not the browser

Why client-side rules are a UX affordance rather than a guarantee, what a constraint buys that a check does not, and the ceilings that stopped request 181.

Published Aug 22, 20265 minutes to read

Everyone knows client-side validation is not security. It gets repeated so often that it has stopped carrying information, and meanwhile the interesting question goes unasked.

The question is not whether to validate on the server. It is how far down the validation should go, and the answer is further than most applications put it.

Three places a rule can live

In the browser. Fast, good UX, trivially bypassed. Its job is telling somebody they typed their email wrong before they wait for a round trip. That is a real job and it is the only one.

In the application server. The usual answer, and mostly correct. The rule runs on every request that goes through that code path.

In the database. A constraint. It runs on every write, from every code path, including the migration you ran by hand, the admin script, the background job somebody wrote in a hurry, and the next version of the application.

The gap between the second and third is smaller than the gap between the first and second, and it is the one that decides whether your dataset is trustworthy in two years.

What a constraint buys that a check does not

An application-layer check is a promise about one code path. A constraint is a property of the data.

The practical difference shows up in the ways bad rows actually get in, and none of them are attacks:

  • A second write path added later that skipped the validation helper.
  • A backfill script run once, by a person, at speed.
  • A bug that only manifests under concurrency, where two requests each pass a check-then-write and one of them should not have.
  • An import from somewhere else.

Every one of those is a Tuesday afternoon rather than a threat model. A constraint refuses all four without anybody remembering it exists — which is the point, because the failure mode of a validation helper is that somebody does not know it is there.

DevForm's answer checks are constraints in Postgres. Not exclusively — there is application-level validation too, because the error messages have to be useful — but the floor is in the database, so a bad answer cannot be written by any route.

The ceilings, and the number that made them concrete

There are limits per response, per network and per form, enforced at the same level.

They are not primarily about abuse. They are about the ordinary case where a public URL gets found by something automated, and the cost is not a security incident — it is a research dataset with junk in it, which you stop reading, and the answers you needed are now mixed into noise you have to sort.

In a probe of the write path, request 181 was refused. That is the sort of number worth writing down: not a policy statement about rate limiting, but the observed point at which the thing actually said no. A limit nobody has watched fire is a limit nobody knows the shape of.

What this costs you

Being honest, because the tradeoff is real.

Error messages get worse. A constraint violation is a Postgres error code, not a sentence a respondent can act on. You need a translation layer, and it will be incomplete at first — the first time an unmapped constraint fires, somebody sees something unhelpful.

Changing the rules means a migration. Loosening a constraint after data exists is more work than editing a validator, and you have to decide what happens to rows that no longer conform.

It is slower to build. Genuinely. A validator is five minutes; a constraint plus a migration plus an error mapping is an hour.

That last one is the reason most products do not do it, and it is the right call for plenty of them. It is the wrong call for a form product, because the entire value of a form product is that the data coming out is worth using.

What is checked

The nine field kinds — short text, paragraph, email, number, date, pick one, pick any, rating on a scale you choose, yes/no — each carry a type and a shape, and both are enforced where they cannot be bypassed. A pick-one answer that is not one of the options is refused. A rating outside the configured scale is refused. A number that is not a number is refused.

Conditional logic is checked at build time rather than submit time, and it warns about the three cases that produce a form which reads correctly and behaves wrongly: a condition referencing a deleted field, one referencing a question that comes later in the form, and one referencing itself.

What is not there

No file uploads, on any plan — the builder control is disabled and the pricing table omits the row rather than showing four crosses that read as an upsell. No notification emails; nothing is sent when a response arrives. No webhooks, no integrations, no payments, no signatures, no calculated fields.

If your form needs a document attached or a Slack ping, this is the wrong tool today and no plan changes that.

Since this is the article a developer reads: check how many rows your export actually returned.

PostgREST caps a query at 1,000 rows by default. A select written to mean "everything" silently returns the first thousand — no error, no warning, a CSV that downloads and opens and looks correct. It truncated an export and an analytics view in this codebase before anybody noticed.

DevForm's export streams by keyset for exactly that reason. If you are querying a PostgREST-backed database of your own, this will happen to you, and the symptom is a number that is quietly wrong rather than an error you can search for.

Read next

All writing