La API de tareas de tu equipo tiene un dashboard de navegador, un backend de Express y PostgreSQL. Usa una sesión del lado del servidor, salvo que otro cliente requiera JWT.
Esa decisión es el primer límite. Y necesitas controles para la estructura de las solicitudes, el acceso a la base de datos, los orígenes del navegador, el agotamiento de recursos, las cabeceras y las credenciales de despliegue. Esta guía recorre esos límites en ese orden y termina con una puerta de lanzamiento que puedes ejecutar antes de producción.
En este artículo
- Mapea los cinco límites antes de añadir middleware
- Para una aplicación propia exclusiva del navegador, elige sesiones
- Los JWT son útiles, pero solo con un plan de salida
- Valida las solicitudes cuando entran en el sistema
- Los límites de velocidad deben seguir el caso de abuso del endpoint
- CORS debe nombrar a los clientes en los que confías
- Helmet es una base, no una revisión de seguridad
- El SQL parametrizado no es negociable
- Trata las variables de entorno como el mínimo
- Ejecuta esta lista de comprobación antes de desplegar
Mapea los cinco límites antes de añadir middleware
La mayoría de las guías de seguridad de Express son listas de compras de middleware. Pero cada control pertenece a un límite y un modo de fallo específicos.
Express te proporciona rutas y composición de middleware. Pero aún tienes que definir la autenticación, la autorización, la validación, los límites de velocidad, CORS, las cabeceras de seguridad, las defensas contra la inyección SQL y el manejo de secretos por tu cuenta. Los controles son sesiones o JWT para la autenticación, validación de esquemas en el límite, límites de velocidad específicos por endpoint, CORS con lista de permitidos, cabeceras de Helmet, SQL parametrizado, secretos gestionados y una lista de comprobación de lanzamiento.
Piensa en cinco límites:
- Identidad: la autenticación identifica al emisor; la autorización comprueba si ese emisor puede realizar esta acción.
- Entrada: los cuerpos de las solicitudes, las cadenas de consulta y los parámetros de ruta necesitan límites de estructura y tamaño.
- Datos: los valores de la base de datos deben mantenerse separados de la sintaxis SQL y se debe comprobar la propiedad.
- Confianza del navegador: CORS define qué orígenes del navegador pueden llamar a rutas con credenciales.
- Disponibilidad y despliegue: los emisores no deben consumir recursos ilimitados y las credenciales de producción no deben filtrarse.
Estos controles se refuerzan entre sí. La autenticación no reparará una inyección SQL. La validación no revocará un token robado. CORS concede permiso a los navegadores; la autenticación protege la ruta. Así que mantenlos como comprobaciones separadas.
Devuelve errores genéricos a los clientes y conserva los detalles de diagnóstico en logs protegidos. Los atacantes leen cuidadosamente los mensajes de error. Un endpoint que indica si un email ya existe revela información útil.
Para una aplicación propia exclusiva del navegador, elige sesiones
Tomemos la API de tareas del equipo. El dashboard del navegador se comunica con una aplicación del lado del servidor, y POST /login establece el acceso a GET /tasks, POST /tasks y PATCH /tasks/:id. Las sesiones del lado del servidor encajan con esa estructura porque la aplicación puede invalidar la sesión cuando el usuario cierra sesión o un administrador deshabilita la cuenta.
La recomendación de priorizar sesiones es más sólida para un navegador y un backend; tu almacén, la política de cookies y la duración de los tokens dependen de tu despliegue y de la combinación de clientes.
| Aspecto | Sesión del lado del servidor | JWT |
|---|---|---|
| Ubicación del estado | Almacén de sesiones del lado del servidor | Token en poder del cliente |
| Revocación | Eliminar o invalidar la sesión | Expiración breve más un diseño de revocación del lado del servidor |
| Exposición en el navegador | Cookie opaca con HttpOnly | Cookie u otro mecanismo de almacenamiento del cliente |
| Varias instancias | Requiere un almacén de sesiones compartido | La verificación puede realizarse entre instancias |
| Mejor uso | Aplicación de navegador con una aplicación del lado del servidor | Clientes móviles, servicio a servicio, IoT o federados |
| Principal carga | Disponibilidad del almacén de sesiones | Diseño de expiración, rotación de refresh y cierre de sesión |
Un almacén de sesiones en memoria desaparece al reiniciar y puede dividir el estado entre las instancias de producción. Usa un almacén de sesiones compartido y duradero cuando la aplicación se ejecute en varias instancias.
Configura las cookies con HttpOnly, Secure y SameSite=Lax o Strict cuando los flujos de tu aplicación lo permitan. La expiración renovable puede prolongar las sesiones activas. Regenera la sesión después del login para que un identificador emitido antes de la autenticación no pueda convertirse en el identificador autenticado:
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
res.sendStatus(204);
});
HttpOnly evita que JavaScript lea la cookie; no hace inofensiva una vulnerabilidad XSS. Un script inyectado aún puede emitir solicitudes autenticadas del mismo origen desde el navegador.
SameSite=None es obligatorio cuando un flujo legítimo de navegador entre sitios debe enviar la cookie, y requiere Secure. En esa arquitectura, las solicitudes con cookies que cambian el estado necesitan una defensa CSRF explícita. Lax o Strict es una opción útil cuando coincide con la topología de tus clientes, no una respuesta universal.
Los JWT justifican su complejidad cuando las credenciales deben viajar a clientes móviles, servicios separados, dispositivos IoT o sistemas federados. La autenticación identifica al emisor; el middleware de autorización debe comprobar después la propiedad o el rol en el límite de la ruta.
Los JWT son útiles, pero solo con un plan de salida
Los payloads de JWT son datos legibles en base64url, no contenido cifrado, así que mantén las contraseñas, los secretos y los datos personales sensibles fuera de ellos.
Un verificador puede aceptar un JWT sin leer una fila de sesión. El cierre de sesión necesita entonces una expiración breve, una lista de bloqueo u otro control del lado del servidor. Para los clientes de navegador, el almacenamiento afecta al riesgo: un payload XSS puede leer un token bearer al que JavaScript tenga acceso. Una cookie HttpOnly limita la lectura del token, mientras que el riesgo de las solicitudes XSS sigue requiriendo solucionar la inyección.
Usa una biblioteca de JWT que fije el algoritmo aceptado durante la verificación y rechace none. Trata el algoritmo de firma y la distribución de claves como decisiones de despliegue. Antes de emitir tokens de larga duración, documenta la rotación de claves y cómo los verificadores reciben la clave pública o el secreto de reemplazo.
El diseño mínimo de refresh es el siguiente:
login:
emitir un JWT de acceso de corta duración
emitir un token refresh opaco aleatorio
almacenar solo su hash, usuario, dispositivo y familia de tokens
refresh:
aplicar hash al token refresh presentado
buscar su registro almacenado
si el token ya se usó:
invalidar toda la familia de tokens
rechazar la solicitud
invalidar el token actual
emitir un nuevo token de acceso y un token refresh
logout:
revocar el token refresh y su sesión del dispositivo
Usa un token refresh aleatorio de 256 bits, almacena solo su hash y rótalo después de cada intercambio. Un token utilizado anteriormente puede indicar un robo. Invalida toda la familia y obliga al usuario a autenticarse de nuevo.
Las duraciones breves de los tokens de acceso reducen la exposición. Las rutas privilegiadas deben volver a comprobar los roles o permisos en el lado del servidor en lugar de confiar en un claim antiguo. “Stateless” describe el estado de verificación; no elimina el trabajo operativo de la revocación.
Valida las solicitudes cuando entran en el sistema
Recorre una solicitud de tarea por el límite. POST /tasks debe aceptar un título y una fecha de calendario ISO opcional. PATCH /tasks/:id debe aceptar solo los campos que la ruta está diseñada para actualizar.
Un esquema estricto puede imponer ese contrato:
import { z } from "zod";
const createTaskSchema = z.object({
title: z.string().trim().min(1).max(200),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
}).strict();
const taskParamsSchema = z.object({
id: z.coerce.number().int().positive()
});
const updateTaskSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional()
}).strict();
function validateTaskRequest(req, res, next) {
const body = createTaskSchema.safeParse(req.body);
const params = taskParamsSchema.safeParse(req.params);
if (!body.success || !params.success) {
return res.status(400).json({ error: "Invalid request" });
}
req.validated = {
body: body.data,
params: params.data
};
next();
}
El ejemplo acepta YYYY-MM-DD; analiza ese valor deliberadamente antes de almacenarlo. Rechaza fechas imposibles como 2026-02-31 y devuelve un único error de validación coherente en lugar de exponer los detalles internos del analizador.
Valida el body, los params, la query y los límites previstos antes de la lógica de negocio o del acceso a la base de datos. Pasa los valores analizados y descarta el objeto original sin límites.
La validación responde a la pregunta: “¿Esta solicitud tiene una estructura y unos límites válidos?”. La codificación de salida gestiona el destino. Escapa el título de una tarea para HTML, email, logs o cualquier destino que lo consuma. El cross-site scripting casi siempre es un problema de salida disfrazado de problema de entrada.
Esta guía utiliza PostgreSQL, por lo que la inyección de operadores de MongoDB y el middleware de contaminación de parámetros quedan fuera de su alcance. Añade defensas específicas del destino cuando tu aplicación realmente gestione esos flujos de datos.
Los límites de velocidad deben seguir el caso de abuso del endpoint
Un limitador global único es fácil de instalar y fácil de configurar mal. Asigna a cada superficie pública una política vinculada al recurso que protege:
| Superficie | Política inicial | Motivo |
|---|---|---|
POST /login | 5 intentos por 15 minutos por IP y cuenta | Ralentiza el credential stuffing |
POST /tasks | 30 solicitudes por minuto por cuenta autenticada | Limita el abuso automatizado de escritura |
GET /tasks | 120 solicitudes por minuto por cuenta autenticada | Deja margen para las actualizaciones del dashboard |
| Formularios públicos | Ajustar por encima de la actividad normal del usuario | Evita penalizar los envíos legítimos |
Esas cifras son puntos de partida. Mide el tráfico normal, ten en cuenta tu proxy inverso y almacena los contadores en un lugar compartido cuando las solicitudes puedan llegar a varias instancias.
Usa una implementación de limitación mantenida y aplica límites a cada superficie pública. Combina el límite de login con una respuesta genérica Invalid credentials para que el endpoint no revele si existe una cuenta.
Los límites de velocidad de la aplicación reducen el volumen de intentos automatizados. No absorben un ataque de denegación de servicio a escala de red. Coloca protección upstream delante de la API y añade límites de tamaño del body y de solicitudes lentas para que un cliente no pueda mantener las conexiones abiertas con payloads pequeños y retrasados.
Esta guía te proporciona el patrón de control; tu tráfico y tu modelo de despliegue determinan las ventanas exactas, la topología del almacén compartido y la duración de los tokens.
CORS debe nombrar a los clientes en los que confías
CORS concede permiso a los navegadores. La autenticación protege la ruta. Configúralos por separado.
Si el dashboard está alojado en https://app.example.com, permite ese origen y habilita las credenciales solo para el grupo de rutas que necesita cookies:
import cors from "cors";
const allowedOrigins = new Set([
"https://app.example.com"
]);
const browserCors = cors({
origin(origin, callback) {
// Decide explícitamente si deben aceptarse las solicitudes sin Origin.
if (origin === "https://app.example.com") {
return callback(null, true);
}
return callback(new Error("Origin not allowed"));
},
credentials: true
});
app.use("/api/browser", browserCors, browserApiRouter);
Nunca combines un origen comodín con solicitudes con credenciales. Las solicitudes sin una cabecera Origin necesitan una decisión de política explícita. CORS no las autentica y los clientes que no son navegadores pueden ignorarlo por completo.
Helmet es una base, no una revisión de seguridad
Las cabeceras son útiles. También es fácil confundirlas con una revisión de seguridad.
Registra Helmet antes de tus rutas e inspecciona las cabeceras resultantes:
import helmet from "helmet";
app.use(helmet());
Para una API que no sirve HTML, la Content Security Policy puede tener poco valor directo. Prueba HSTS solo cuando todas las rutas de acceso de producción sean HTTPS y tu proxy reenvíe correctamente el esquema de la solicitud.
| Helmet ayuda con | Helmet no puede solucionar |
|---|---|
| Varias cabeceras de seguridad del navegador | Autorización ausente |
| Protecciones contra framing y tipos de contenido | SQL inseguro |
| Política base del navegador | Secretos filtrados o tokens refresh robados |
Consulta la documentación del paquete de la versión antes de confiar en el comportamiento predeterminado y prueba las cabeceras con el frontend real. Una base de cabeceras merece la pena. ¿Son seguras las rutas? Las pruebas tienen que responderlo.
El SQL parametrizado no es negociable
Para los valores de PostgreSQL, los placeholders mantienen el valor vinculado separado de la sintaxis SQL. No parametrizan identificadores ni expresiones de ordenación.
Úsalos para buscar la tarea:
const { id } = taskParamsSchema.parse(req.params);
const result = await pool.query(
`SELECT id, title, due_date
FROM tasks
WHERE id = $1 AND owner_id = $2`,
[id, req.user.id]
);
El predicado owner_id es la comprobación de autorización: evita que un usuario autenticado lea la tarea de otro usuario. La parametrización y la autorización resuelven fallos diferentes.
Una expresión de ordenación cambia la estructura de la consulta, así que asigna un valor de la solicitud a SQL controlado por el código fuente:
const sortOrders = {
newest: "created_at DESC",
due: "due_date ASC"
};
const orderBy = sortOrders[req.query.sort] ?? sortOrders.newest;
const result = await pool.query(
`SELECT id, title, due_date
FROM tasks
WHERE owner_id = $1
ORDER BY ${orderBy}`,
[req.user.id]
);
Esto sigue siendo seguro solo porque cada valor del objeto y el fallback son literales mantenidos en tu código fuente. Nunca construyas valores de una lista de permitidos a partir de datos de la solicitud o de la base de datos.
La interpolación de strings en SQL es un fallo de revisión, incluso cuando el valor proviene actualmente de una variable interna supuestamente confiable. Los query builders y los ORM también requieren revisar sus APIs y el SQL que generan.
Trata las variables de entorno como el mínimo
.env es un punto de partida útil. Producción aún necesita una estrategia de gestión de secretos. Una credencial confirmada en Git y eliminada posteriormente sigue necesitando rotación.
Para el desarrollo local:
- Añade
.enva.gitignoreantes del primer commit. - Confirma
.env.examplecon los nombres de las variables y valores vacíos. - Valida las variables obligatorias al iniciar y falla de forma visible cuando falte alguna.
- Analiza el árbol de trabajo actual y el historial del repositorio.
Si tu despliegue ejecuta una versión de Node compatible con --env-file, puede cargar archivos de entorno locales sin un paquete adicional:
node --env-file=.env server.js
Verifica ese comando con la versión de Node que despliegues.
Para producción, avanza por esta escala de madurez:
- Elimina las credenciales codificadas directamente.
- Usa un archivo de entorno local ignorado por git.
- Carga los valores de producción desde un almacén de secretos gestionado.
- Inyéctalos en tiempo de ejecución mediante identidades con privilegios mínimos.
- Prefiere credenciales de corta duración y OIDC cuando tu plataforma los admita.
AWS Secrets Manager, Google Secret Manager, Azure Key Vault y Vault son ejemplos de almacenes gestionados. La propiedad importante es el modelo operativo. La aplicación recibe solo las credenciales que necesita y esas credenciales pueden rotarse sin reconstruir el código fuente.
Analiza el repositorio, el historial de Git, el contexto de compilación del contenedor y la configuración de despliegue en busca de valores con forma de secreto. Una comprobación pre-commit detecta la próxima filtración; el análisis del historial detecta la anterior.
Ejecuta esta lista de comprobación antes de desplegar
Trata estos puntos como bloqueadores del lanzamiento. Un documento de cuarenta elementos no protege nada si nadie lo ejecuta.
Identidad
- Las rutas protegidas usan middleware de autenticación compartido, seguido de una comprobación de propiedad o rol.
- Las sesiones establecen
HttpOnly,Securey unSameSiteapropiado para la arquitectura; el login regenera la sesión. - La verificación de JWT fija los algoritmos y los tokens de acceso expiran rápidamente.
- Los tokens refresh son opacos, tienen hash, se rotan y se revocan al cerrar sesión; la reutilización invalida la familia de tokens.
Solicitudes y datos
- El body, la query y los parámetros de ruta se validan antes de la lógica de negocio.
- Se aplican límites de tamaño del body, de tiempo de solicitud y de velocidad específicos por endpoint.
- El login devuelve un mensaje de error genérico.
- La salida se escapa para su destino.
- Los valores SQL utilizan placeholders; los fragmentos estructurales proceden únicamente de listas de permitidos controladas por el código fuente.
- CORS nombra orígenes conocidos y las respuestas con credenciales nunca utilizan
*. - Helmet se ejecuta antes de las rutas y sus cabeceras funcionan con el frontend de producción.
Despliegue
- La ausencia de configuración obligatoria detiene el inicio.
- Los secretos están ausentes del repositorio, el historial de Git, el contexto de compilación, la imagen del contenedor y la configuración de despliegue.
- Las credenciales expuestas se han rotado.
- Las identidades de producción solo tienen los permisos que necesitan.
- Los errores externos son genéricos; los fallos detallados se envían a logs protegidos.
- Las versiones de las dependencias desplegadas y la configuración de seguridad se han revisado según su documentación oficial actual.
Si falla una comprobación, retrasa el despliegue, asigna un responsable y vuelve a ejecutar la puerta después de corregirlo. El trabajo de seguridad se vuelve real cuando alguien puede bloquear el lanzamiento. Asigna a esa persona un responsable claro y una fecha para volver a ejecutarlo.