Construyendo un endpoint validado
El middleware ya está escrito y el lenguaje de los schemas ya te resulta familiar. Lo que falta es la ruta donde ambos se encuentran.
El formulario de registro ha estado enviando datos a /api/register-vulnerable desde el inicio de esta sección, un endpoint que se salta cualquier verificación. Este capítulo construye el que lo reemplaza.
Conectando el middleware a una ruta
Express recibe el middleware entre la ruta y el 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 } })
},
)Tres piezas: la ruta, la verificación y el handler. El handler solo se ejecuta después de que todo el middleware anterior haya terminado con éxito, así que una solicitud que falla la validación nunca llega a él.
Apunta el formulario a la nueva ruta y el endpoint anterior queda fuera de juego:
// front-end/app.ts
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})Es un detalle pequeño que se acumula. Las reglas viven en un lugar visible, y el handler queda dedicado únicamente al trabajo real.
El primer schema
Empieza con dos campos, suficientes para probar la conexión:
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'),
})Envía el formulario vacío y ambos mensajes vuelven en el 400, listos para que la página los muestre junto a cada campo. Envía una dirección real y una contraseña suficientemente larga y el handler se ejecuta.
Ese es el ciclo funcionando de principio a fin. Todo lo que sigue es ir completando el schema.
Logra que un campo se rechace y otro pase, y luego agrega el resto. Depurar un schema de nueve campos que nunca ha funcionado ni una vez es una tarde mucho peor.
Sacando el schema a su propio archivo
Un schema en línea está bien para dos campos y deja de estarlo cuando llegas a nueve. Dale su propio archivo:
// 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>La ruta importa ambas cosas:
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'
const userData: RegisterInput = (req as any).validatedDataAhora el schema es reutilizable, el tipo se obtiene de él automáticamente, y los datos del handler quedan tipados. Agrega un campo al schema y RegisterInput lo incorpora sin necesidad de una segunda edición.
Ahora una sola definición hace dos trabajos: verifica los datos mientras el programa corre, y le indica a TypeScript la forma de los datos mientras escribes código. Ninguno de los dos puede desalinearse del otro, porque solo existe uno.
Haciendo crecer el schema
Ahora los campos reales, cada uno indicando qué acepta y qué le hace al 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(),
})Hay cuatro cosas ocurriendo a lo largo de estos campos.
Todo string tiene un máximo. Eso cierra el problema de entrada sobredimensionada visto en denegación de servicio con una sola línea por campo.
age se convierte con coerción. Los formularios HTML envían strings, así que z.coerce.number() convierte antes de verificar, y .int() rechaza 21.5.
email se normaliza antes de validarse. .trim() y .toLowerCase() se ejecutan primero, y luego .pipe() le pasa el valor limpio a la verificación de email.
bio es opcional. Omitirlo está bien; darle 600 caracteres no.
El orden importa en una cadena
z.email().trim() valida primero y recorta espacios después, así que una dirección enviada con un espacio inicial de sobra falla antes de que el recorte pueda ayudarla. Verificado en Zod 4.5.4: ' [email protected] ' es rechazado. Limpia el valor primero y luego valida el resultado, que es justamente para lo que existe .pipe().
Ese es el atractivo de describir datos de esta forma. Nueve campos de reglas, y aun así puedes verificar cualquiera de ellos con solo leer una oración.
Lo que se devuelve
El handler devuelve solo los campos que un cliente debería 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 y bio se validan y no se devuelven. Construir la respuesta campo por campo es lo que mantiene esa garantía, porque un nuevo campo en el schema no puede filtrarse en la respuesta por accidente.
La contraseña se verifica cuidadosamente y nunca se envía de vuelta. Nombrar cada campo en la respuesta es lo que hace que eso se mantenga así a medida que el schema crece.
Ponlo a prueba
Este schema tiene tres problemas. Uno rechaza entradas válidas, otro acepta entradas que no debería, y otro filtra información.
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 })
})Compara tus respuestas
1. z.email().trim() rechaza entradas válidas. El recorte se ejecuta después de la verificación de email, así que una dirección pegada con un espacio final falla la validación antes de poder limpiarse. Verificado en Zod 4.5.4. Usa z.string().trim().pipe(z.email()).
2. displayName no tiene un máximo, y age no tiene coerción. Dos problemas, uno en cada línea. La falta de .max() significa que el campo acepta un valor de cualquier longitud, que es exactamente la denegación de servicio vista antes en esta sección, ahora llegando a través de un formulario. Y z.number() rechaza el string que un formulario HTML realmente envía, así que age necesita z.coerce.number(), además de .int() y un .max() ya que estamos en eso.
3. user: data devuelve todo. Cada campo que el schema valida vuelve al cliente, incluyendo cualquiera que se agregue después. Nombra explícitamente los campos que quieres devolver.
Una versión corregida:
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 },
})
})Hacia dónde va esto
La sección comenzó con un formulario que confiaba en todo y termina con uno que confía solo en lo que ha verificado. Nueve campos describen qué acepta el servidor, el middleware los impone antes de que se ejecute cualquier handler, y la respuesta dice solo lo que se propone decir.
Los tres ataques que abrieron la sección necesitaban una entrada que nadie hubiera revisado. Eso ahora se maneja en el límite de entrada, una sola vez, en un archivo que puedes leer.
Autenticación vs. autorización da inicio a la siguiente pregunta. Un endpoint de registro crea una cuenta, y después de eso la aplicación no tiene idea de quién es quién en la solicitud.

