Construindo um endpoint validado
O middleware já está escrito e a linguagem do schema já é familiar. O que falta é a rota onde os dois se encontram.
O formulário de cadastro vem enviando dados para /api/register-vulnerable desde o início desta seção, um endpoint que não faz nenhuma verificação. Este capítulo constrói o endpoint que vai substituí-lo.
Ligando o middleware a uma rota
O Express recebe o middleware entre o caminho e o 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 } })
},
)Três posições: o caminho, a verificação, o handler. O handler só roda depois que todo middleware anterior a ele tiver terminado com sucesso, então uma requisição que falha na validação nunca chega até ele.
Aponte o formulário para o novo caminho e o endpoint antigo sai de cena:
// front-end/app.ts
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})É um detalhe pequeno que faz diferença. As regras ficam visíveis em um lugar só, e o handler fica livre para fazer só o trabalho de verdade.
O primeiro schema
Comece com dois campos, o suficiente para provar que a conexão funciona:
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'),
})Envie o formulário vazio e as duas mensagens voltam no 400, prontas para a página exibir ao lado de cada campo. Envie um endereço real e uma senha longa o suficiente e o handler roda.
Esse é o ciclo funcionando de ponta a ponta. Tudo depois disso é só ir preenchendo o schema.
Deixe um campo rejeitando e um campo passando, depois adicione o resto. Depurar um schema de nove campos que nunca funcionou nem uma vez é uma tarde bem mais difícil.
Movendo o schema para fora
Um schema inline é aceitável para dois campos e passa a ser errado quando já são nove. Dê a ele um arquivo próprio:
// 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>A rota importa os dois:
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'
const userData: RegisterInput = (req as any).validatedDataAgora o schema é reutilizável, o tipo sai dele automaticamente, e os dados do handler ficam tipados. Adicione um campo ao schema e RegisterInput ganha esse campo sem uma segunda edição.
Uma definição só agora faz dois trabalhos: verifica os dados enquanto o programa roda, e diz ao TypeScript qual é o formato enquanto você escreve o código. Um não pode se desalinhar do outro, porque só existe uma definição.
Fazendo o schema crescer
Agora os campos de verdade, cada um dizendo o que aceita e o que faz com o valor:
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(),
})Quatro coisas estão acontecendo ao longo desses campos.
Toda string tem um máximo. Isso resolve o problema de entrada superdimensionada de denial of service em uma linha por campo.
age sofre coerção. Formulários HTML enviam strings, então z.coerce.number() converte antes de verificar, e .int() rejeita 21.5.
email é normalizado antes de ser validado. .trim() e .toLowerCase() rodam primeiro, depois .pipe() passa o valor já limpo para a verificação de e-mail.
bio é opcional. Deixar de fora tudo bem; passar 600 caracteres, não.
A ordem importa em uma cadeia
z.email().trim() valida primeiro e só depois corta os espaços, então um endereço enviado com um espaço perdido no início falha antes que o .trim() possa ajudar. Verificado no Zod 4.5.4: ' [email protected] ' é rejeitado. Limpe o valor primeiro e valide o resultado, que é para isso que serve o .pipe().
É essa a vantagem de descrever dados dessa forma. Nove campos de regras, e você ainda consegue conferir qualquer uma delas lendo uma frase.
O que volta na resposta
O handler retorna só os campos que um cliente deveria ver:
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 e bio são validados e não são retornados. Construir a resposta campo por campo é o que garante isso, porque um novo campo no schema não pode vazar para a resposta por acidente.
A senha é conferida com cuidado e nunca é enviada de volta. Nomear cada campo na resposta é o que faz isso continuar valendo à medida que o schema cresce.
Tente você mesmo
Este schema tem três problemas. Um rejeita entrada válida, um aceita entrada que não deveria, e um vaza dado.
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 suas respostas
1. z.email().trim() rejeita entrada válida. O trim roda depois da checagem de e-mail, então um endereço colado com um espaço no final falha na validação antes de poder ser limpo. Verificado no Zod 4.5.4. Use z.string().trim().pipe(z.email()).
2. displayName não tem máximo, e age não passa por coerção. Dois problemas em uma linha cada. A falta de .max() significa que o campo aceita um valor de qualquer tamanho, o que é a negação de serviço de mais cedo nesta seção chegando através de um formulário. E z.number() rejeita a string que um formulário HTML realmente envia, então age precisa de z.coerce.number(), mais um .int() e um .max() já que você está mexendo nisso.
3. user: data retorna tudo. Todo campo que o schema valida volta para o cliente, incluindo qualquer um que for adicionado depois. Nomeie os campos que você pretende retornar.
Uma versão corrigida:
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 },
})
})Para onde isso vai a seguir
A seção começou com um formulário que confiava em tudo e termina com um que confia apenas no que já verificou. Nove campos descrevem o que o servidor aceita, o middleware os impõe antes de qualquer handler rodar, e a resposta diz só o que pretende dizer.
Os três ataques que abriram a seção precisavam, todos, de uma entrada que ninguém tinha examinado. Isso agora é tratado na fronteira, uma vez só, em um arquivo que você consegue ler.
Autenticação vs autorização começa a próxima pergunta. Um endpoint de cadastro cria uma conta, e o app não tem a menor ideia de quem é quem nas requisições depois disso.

