Skip to content

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:

ts
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:

ts
// front-end/app.ts
const response = await fetch('/api/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(formData),
})
JunoWiring the middleware to a route Reading the route definition tells you what it accepts, without opening the handler.

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.

JunoWiring the middleware to a route Building the secure route beside the vulnerable one, then switching the form over, is worth copying as a habit. The old path keeps working while the new one is proven, so nothing is broken mid-change.

The step people forget is the last one: delete the old endpoint once the form has moved. An unused vulnerable route is still a live route, and nothing about it stops answering because your front end stopped calling it.

JunoWiring the middleware to a route Order in the chain is behaviour, not style. Middleware runs top to bottom, so validation placed after an authentication check only sees requests that proved who they are.

Rate limiting usually belongs first, because parsing a body costs more than counting a request, and an attacker sending large malformed payloads is buying your CPU cheaply otherwise.

The 201 is worth getting right too: it means created, and it pairs with a Location header pointing at the new resource.

The first schema

Start with two fields, enough to prove the wiring:

ts
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.

JunoThe first schema Two fields is deliberate. It's small enough to see the whole path working before there's much to be wrong.

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.

JunoThe first schema Test the failing path before the happy one. It's tempting to type valid input and see a 201, and that only proves the handler runs.

Submitting nothing should give you a 400 with a message per field. If it gives you a 201, the middleware isn't wired in and a valid submission would have told you nothing.

JunoThe first schema A minimum length is the control most services stop at, and 8 is a low bar. Length is the strongest single factor, so 12 or more is current guidance.

More useful is checking against known-breached passwords, because "Password123!" satisfies every complexity rule ever written, while composition rules push people toward predictable substitutions and a sticky note.

Storage is the other half: hash with bcrypt or argon2, never keep the original.

Moving the schema out

An inline schema is fine for two fields and wrong by the time there are nine. Give it a file:

ts
// 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:

ts
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'

const userData: RegisterInput = (req as any).validatedData

Now 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.

JunoMoving the schema out The file move is the ordinary kind of tidying. The type export is the part worth noticing.

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.

JunoMoving the schema out A schemas folder becomes the place to look for what your API accepts, which is useful well beyond validation. It's the honest answer to "what does this endpoint take", and it stays honest because it's the code doing the checking.

Keep related schemas together and compose them. A loginSchema can pick fields off a user schema instead of restating them, and the rules stay in one place.

JunoMoving the schema out Because the schema is a plain module, the front end can import the same file and check the form before posting. One definition, two places, no drift, and the server check is still the one that counts.

Push the type further than the handler. Give service functions RegisterInput as their parameter type and the compiler stops anyone calling them with an unvalidated object, turning "always validate first" into something the build enforces.

One caution: z.infer gives the output type, so with coercion in the schema it describes the post-parse shape. That's the one you want, and another reason handlers should never reach for req.body.

Growing the schema

Now the real fields, each one saying what it accepts and what it does to the value:

ts
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.

JunoGrowing the schema Read one field at a time and each line is a small, plain rule. A name is text, tidied of stray spaces, between 2 and 50 characters, using only the characters names use.

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.

JunoGrowing the schema Normalising in the schema is what keeps storage consistent. Without toLowerCase, [email protected] and [email protected] are two accounts, and you find out when someone cannot log in.

Be careful with a regex on a name. The pattern here rejects every name written outside the Latin alphabet, and plenty written inside it. A length limit is usually the right control, and if you need a character check, decide deliberately which alphabets it excludes.

JunoGrowing the schema The password regex is the one to argue with. Restricting to letters, numbers and underscores blocks spaces and symbols, ruling out passphrases and anything a password manager generates, while doing nothing an attacker cares about: the value is hashed and never interpreted.

A character allowlist on a password is usually a leftover from an era of unsafe query building, and the answer there was parameterization. Set a generous maximum and accept everything else.

That maximum matters more than it looks. bcrypt truncates at 72 bytes, so without one the end of a long passphrase is silently ignored, and hashing is deliberately slow, so an unbounded field aims a denial of service at your own CPU.

What comes back

The handler returns only the fields a client should see:

ts
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.

JunoWhat comes back Validating a field and returning a field are separate decisions.

The password is checked carefully and never sent back. Naming each field in the response is what makes that hold as the schema grows.

JunoWhat comes back The pattern to avoid is res.json({ user: userData }). It works today and turns every future schema field into a response field, so the day someone adds a field for internal use it goes out to every client.

Naming fields explicitly is a few more lines and one fewer way to leak something.

JunoWhat comes back The durable version is an output schema: a second Zod schema describing the response, parsed on the way out. What your API returns is then described in one place, checked, and impossible to widen by accident. That's a contract which cannot drift from the code.

Date.now() as an id is fine for a mock and wrong in production: it collides under concurrency and leaks creation time. A UUID, the 128-bit random identifier format, or a database sequence is the real answer.

Try it

This schema has three problems. One rejects valid input, one accepts input it shouldn't, and one leaks.

ts
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:

ts
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.