Building a validated endpoint
The middleware is written and the schema language is familiar. What's left is the route they meet on.
The registration form has been posting to /api/register-vulnerable since the start of this section, an endpoint that skips every check. This chapter builds the one that replaces it.
Wiring the middleware to a route
Express takes middleware between the path and the handler:
import { validate } from '../middleware/validate.js'
import { registerSchema } from '../schemas/userSchema.js'
router.post(
'/api/register',
validate(registerSchema),
(req, res) => {
const userData = (req as any).validatedData
res.status(201).json({ success: true, user: { email: userData.email } })
},
)Three slots: the path, the check, the handler. The handler runs only after every middleware before it has finished successfully, so a request failing validation never reaches it.
Point the form at the new path and the old endpoint is out of the picture:
// front-end/app.ts
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})That's a small thing that adds up. The rules live somewhere you can see them, and the handler is left doing the actual work.
The first schema
Start with two fields, enough to prove the wiring:
import * as z from 'zod'
const registerSchema = z.object({
email: z.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})Submit the form empty and both messages come back in the 400, ready for the page to put beside each field. Submit a real address and a long enough password and the handler runs.
That's the loop working end to end. Everything after this is filling the schema in.
Get one field rejecting and one field passing, then add the rest. Debugging a nine-field schema that has never worked once is a much worse afternoon.
Moving the schema out
An inline schema is fine for two fields and wrong by the time there are nine. Give it a file:
// back-end/schemas/userSchema.ts
import * as z from 'zod'
export const registerSchema = z.object({
email: z.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export type RegisterInput = z.infer<typeof registerSchema>The route imports both:
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'
const userData: RegisterInput = (req as any).validatedDataNow the schema is reusable, the type comes off it automatically, and the handler's data is typed. Add a field to the schema and RegisterInput gains it without a second edit.
One definition now does two jobs: it checks data while the program runs, and it tells TypeScript the shape while you write. Neither can drift from the other, because there's only one of them.
Growing the schema
Now the real fields, each one saying what it accepts and what it does to the value:
export const registerSchema = z.object({
name: z
.string('Name is required')
.trim()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name must be 50 characters or fewer')
.regex(/^[a-zA-Z\s\-'.]+$/, 'Letters, spaces, hyphens, apostrophes and periods only'),
username: z
.string('Username is required')
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be 20 characters or fewer'),
email: z
.string('Email is required')
.trim()
.toLowerCase()
.pipe(z.email('Enter a valid email address')),
age: z.coerce
.number('Age is required')
.int('Age must be a whole number')
.min(13, 'You must be at least 13')
.max(120, 'Enter a real age'),
password: z
.string('Password is required')
.min(8, 'Password must be at least 8 characters')
.regex(/^[A-Za-z0-9_]+$/, 'Letters, numbers and underscores only'),
bio: z.string().max(500, 'Bio must be 500 characters or fewer').optional(),
})Four things are happening across those fields.
Every string has a maximum. That closes the oversized-input problem from denial of service in one line per field.
age is coerced. HTML forms send strings, so z.coerce.number() converts before checking, and .int() rejects 21.5.
email is normalised before it's validated. .trim() and .toLowerCase() run first, then .pipe() hands the cleaned value to the email check.
bio is optional. Leaving it out is fine; giving it 600 characters is not.
Order matters in a chain
z.email().trim() validates first and trims second, so an address submitted with a stray leading space fails before the trim can help it. Verified on Zod 4.5.4: ' [email protected] ' is rejected. Clean the value first and validate the result, which is what .pipe() is for.
That's the appeal of describing data this way. Nine fields of rules, and you can still check any one of them by reading a sentence.
What comes back
The handler returns only the fields a client should see:
const userData: RegisterInput = (req as any).validatedData
// Real work would go here: hash the password with bcrypt or argon2,
// store the user with parameterized queries, send a verification email.
res.status(201).json({
success: true,
user: {
id: Date.now(),
name: userData.name,
username: userData.username,
email: userData.email,
},
})password, age and bio are validated and not returned. Building the response field by field is what keeps it that way, because a new schema field cannot leak into the response by accident.
The password is checked carefully and never sent back. Naming each field in the response is what makes that hold as the schema grows.
Try it
This schema has three problems. One rejects valid input, one accepts input it shouldn't, and one leaks.
export const profileSchema = z.object({
email: z.email().trim(),
displayName: z.string().min(2),
age: z.number().min(13),
})
router.post('/api/profile', validate(profileSchema), (req, res) => {
const data = (req as any).validatedData
res.status(201).json({ success: true, user: data })
})Compare your answers
1. z.email().trim() rejects valid input. The trim runs after the email check, so an address pasted with a trailing space fails validation before it can be cleaned. Verified on Zod 4.5.4. Use z.string().trim().pipe(z.email()).
2. displayName has no maximum, and age is not coerced. Two problems in one line each. No .max() means the field accepts a value of any length, which is the denial of service from earlier in this section arriving through a form. And z.number() rejects the string an HTML form actually sends, so age needs z.coerce.number(), plus .int() and a .max() while you're there.
3. user: data returns everything. Every field the schema validates goes back to the client, including any added later. Name the fields you mean to return.
A fixed version:
export const profileSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email('Enter a valid email address')),
displayName: z.string().trim().min(2).max(50),
age: z.coerce.number().int().min(13).max(120),
})
router.post('/api/profile', validate(profileSchema), (req, res) => {
const data = (req as any).validatedData
res.status(201).json({
success: true,
user: { displayName: data.displayName, email: data.email },
})
})Where this goes next
The section started with a form that trusted everything and ends with one that trusts what it has checked. Nine fields describe what the server accepts, the middleware enforces them before any handler runs, and the response says only what it means to say.
The three attacks that opened the section all needed input nobody had looked at. That's now handled at the boundary, once, in a file you can read.
Authentication vs authorization starts the next question. A registration endpoint creates an account, and the app has no idea who anyone is on the request after that.

