Validation Placement: Server First, Shared Schemas, DB Backstop
"Where should validation live?" is a question junior engineers answer wrong in one direction (only on the client — "the form checks it") and paranoid engineers answer wrong in the other (duplicate every rule everywhere, by hand). The correct architectural answer follows from one principle: validation is about the trust boundary, and the trust boundary is the server — so the non-negotiable rule is the server must validate everything, always, because the client is fully under the user's control (they can bypass your form, replay requests, use the API directly, or disable JavaScript). Client-side validation is real and valuable, but for a different reason: it exists for user experience (instant feedback, no round-trip to learn a field is wrong), not for security or data integrity. So the first architectural fact is: client validation is a UX optimization; server validation is a correctness/security requirement — you need both, for different reasons, and you can never skip the server one. This immediately creates a problem the naive approach handles badly: you have the same rules in two places (email format, password length, "quantity ≤ stock"), and if you hand-write them twice, they drift — the client says the password is fine, the server rejects it, and the user is confused. The architectural solution is a single source of truth for validation logic, shared across the boundary — a schema (Zod, Yup, JSON Schema, io-ts) defined once and run on both client and server. This is the key modern pattern: define the schema once, import it on the client (for instant UX feedback) and on the server (for the authoritative check), so the rules can't drift because there's only one definition. It also gives you types for free (the schema infers the TypeScript type), collapsing validation and typing into one artifact. But schema-sharing only covers syntactic/format validation (shape, type, range, regex) — the rules you can check from the data alone. Semantic validation (rules that need state the client can't see: "is this username taken?", "is there enough inventory?", "does this user have permission?") must live on the server, because the client doesn't have the data to check them (and even if it did, it couldn't be trusted). So the layered model is: shared schema for format (both sides, UX + first-line integrity) → server-only for semantic/stateful rules (authoritative, needs data the client lacks) → database constraints as the last line (uniqueness, foreign keys — the final backstop even if application code has a bug). The database constraint layer is the often-forgotten one: a UNIQUE constraint catches a race condition (two requests both pass the "is username taken" check simultaneously) that application-level validation can't. The subtle traps: trusting client validation for security (the cardinal sin — the client is the attacker's tool); duplicating rules by hand (they drift — share a schema); putting semantic rules on the client (it lacks the data, and can't be trusted); and forgetting database constraints (they catch races application code misses). The mental model: validation is about the trust boundary (the server), so the server must validate everything (client is user-controlled) — client validation is a UX optimization (instant feedback), never a security control; share a single schema (Zod/Yup) across both sides for format validation so rules can't drift (and get types for free), keep semantic/stateful rules (uniqueness, inventory, permissions) server-only because the client lacks the data and can't be trusted, and use database constraints as the last-line backstop (catching races application validation misses).
Where Validation Lives at a Glance
| Layer | What it validates | Purpose | Trust |
|---|---|---|---|
| client | format/shape (via shared schema) | UX — instant feedback | ❌ untrusted (user-controlled) |
| server | format + semantic/stateful | correctness/security (authoritative) | ✅ the trust boundary |
| shared schema | format (one definition, both sides) | no drift + types for free | — |
| database constraint | uniqueness, FKs, ranges | last-line backstop (catches races) | ✅ final |
GIF via GIPHY
| Rule type | Where it MUST live |
|---|---|
| format (email, length, range, regex) | shared schema → both |
| semantic/stateful (unique, inventory, permission) | server only (client lacks data) |
| uniqueness under concurrency | database constraint (catches races) |
The Trust Boundary: Why the Server Is Non-Negotiable
The foundational principle is that validation exists to protect the trust boundary, and in a client-server app, the client is entirely under the user's control — so it can never be trusted for correctness or security:
The client is the ATTACKER'S TOOL:
- they can bypass your form and call the API directly (curl, Postman)
- they can replay or modify requests
- they can disable JavaScript entirely
- they can tamper with client state in devtools
→ any validation ONLY on the client is NO validation from a security/integrity standpoint.
THE RULE: the SERVER must validate EVERYTHING, always. Non-negotiable.
Client validation exists for a DIFFERENT reason — UX:
- instant feedback ("email is invalid") without a server round-trip
- guiding the user before they submit
→ it's a UX OPTIMIZATION, never a security control. You need BOTH, for different reasons.
The cardinal architectural sin is trusting client validation for security — "the form won't let them submit an invalid quantity" is meaningless when the user can POST any quantity directly to the API. Client validation improves the experience of well-behaved users; it does nothing against a malicious or buggy client. So the server validates everything as if the client validated nothing — and the client validates for UX as a bonus. This dual-purpose framing resolves the junior mistake (client-only) and clarifies why you can't skip the server.
Sharing a Schema, and the Format-vs-Semantic Split
Having established you validate on both sides, the next problem is rule drift: the same format rules (email, password length, quantity range) hand-written twice diverge over time. The solution is a single schema as the source of truth, run on both sides:
ONE schema definition (Zod / Yup / JSON Schema), defined once:
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(13),
});
→ import on the CLIENT: instant UX validation before submit.
→ import on the SERVER: the authoritative format check.
→ the rules CAN'T drift (one definition); and you get the TypeScript TYPE for free
(z.infer<typeof UserSchema>) — validation and typing collapse into one artifact.
But shared schemas only cover syntactic/format validation — rules checkable from the data alone (shape, type, range, pattern). The other category, semantic/stateful validation, needs state the client doesn't have and can't be trusted with:
GIF via GIPHY
FORMAT (shared schema, both sides): email format, password length, quantity is a positive int.
→ checkable from the DATA ALONE → safe to run on the client for UX.
SEMANTIC / STATEFUL (server ONLY): "is this username taken?" (needs the user DB)
"is there enough inventory?" (needs live stock)
"does this user have permission?" (needs auth state)
→ needs DATA THE CLIENT LACKS, and even if the client had it, it couldn't be TRUSTED.
→ these live on the server, period.
DATABASE CONSTRAINTS (last line): UNIQUE(email), foreign keys, CHECK constraints.
→ catches RACE CONDITIONS application validation misses: two requests both pass the
"is username taken?" check simultaneously → the DB's UNIQUE constraint rejects the second.
The layering is: shared schema (format, both sides) → server-only (semantic/stateful) → database constraints (last-line backstop). The database layer is the most-forgotten: application-level "is X unique?" checks have a time-of-check-to-time-of-use race (two concurrent requests both see "available" and both insert), which only a database UNIQUE constraint catches. So even a perfectly-validated application needs the DB constraint as the final integrity guarantee.
In Practice: What Goes Wrong
Scenario 1: The Client-Only Validation Bypassed
A checkout only validated "quantity ≤ stock" in the React form; an attacker POSTed a quantity of 10,000 directly to the API, oversold the inventory, and the order went through. Root cause: validation lived only on the client, which is user-controlled and bypassable. Fix: validate on the server (the trust boundary) — the client check is UX only. Any rule that matters for correctness or security must be enforced server-side; client validation is a convenience for honest users, not a control against malicious ones.
Scenario 2: The Rules That Drifted
GIF via GIPHY
A form's password rule (min 8 chars) was hand-written on the client and server; a later change updated the client to min 12 but missed the server, so users got "looks good" then a server rejection. Root cause: the same rule hand-duplicated in two places drifted when one was updated. Fix: define the rule once in a shared schema (Zod) imported by both client and server — the rule can't drift because there's one definition. Rule duplication guarantees eventual drift; a shared schema is the single source of truth (and gives types for free).
Scenario 3: The Race Condition Uniqueness Check Missed
An "is username taken?" check ran in application code (query the DB, if not found, insert); under concurrency, two signups with the same username both passed the check and both inserted, creating a duplicate. Root cause: application-level uniqueness validation has a time-of-check-to-time-of-use race — the check and the insert aren't atomic. Fix: a database UNIQUE constraint as the last line — the DB rejects the second insert atomically, catching the race application code can't. Semantic checks in application code need a database-constraint backstop for anything that must be unique under concurrency.
Tradeoffs and Engineering Decisions
- Server validates everything; client validates for UX. The trust boundary is the server (the client is user-controlled and bypassable), so the server must validate all rules as if the client validated nothing. Client validation is a UX optimization (instant feedback), never a security control — the cardinal sin is trusting it for correctness. You need both, for different reasons.
- Share a schema for format rules — no drift, free types. Define format/shape validation once (Zod/Yup/JSON Schema), run it on both client (UX) and server (authoritative). This eliminates rule drift (one definition) and yields the TypeScript type for free. Hand-duplicating rules guarantees eventual divergence.
- Semantic/stateful rules are server-only. Uniqueness, inventory, permissions — anything needing state the client lacks — must live on the server, because the client doesn't have the data (and couldn't be trusted with it). Don't try to replicate these client-side; you can't, and shouldn't.
- Database constraints are the last line. Application-level semantic checks have concurrency races (time-of-check-to-time-of-use); database
UNIQUE/foreign-key/CHECKconstraints catch what application code misses (two concurrent requests both passing a uniqueness check). Even perfectly-validated application code needs this backstop for integrity under concurrency.
GIF via GIPHY
Key Takeaways
- Validation is about the trust boundary — the server — so the server must validate everything, always (the client is user-controlled: it can bypass forms, call the API directly, replay requests, disable JS). Client validation is a UX optimization (instant feedback), never a security control; you need both for different reasons.
- Share a single schema (Zod/Yup/JSON Schema) across client and server for format validation so rules can't drift (one definition) — and get the TypeScript type for free (validation + typing in one artifact).
- Format rules (shape, type, range, regex — checkable from data alone) live in the shared schema on both sides; semantic/stateful rules (uniqueness, inventory, permissions — needing data the client lacks and can't be trusted with) are server-only.
- Database constraints are the last-line backstop —
UNIQUE, foreign keys,CHECK— catching concurrency races (two requests both passing an application-level uniqueness check) that application validation can't. - The layered model: shared schema (format, both sides) → server-only (semantic/stateful) → database constraints (integrity backstop). The traps: trusting client validation for security, hand-duplicating rules (drift), putting semantic rules on the client (it lacks the data), and forgetting DB constraints (races).
GIF via GIPHYWhat did you think?