Cómo proteger una API de Node.js antes de producción

Tue Aug 18 2026

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

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.

AspectoSesión del lado del servidorJWT
Ubicación del estadoAlmacén de sesiones del lado del servidorToken en poder del cliente
RevocaciónEliminar o invalidar la sesiónExpiración breve más un diseño de revocación del lado del servidor
Exposición en el navegadorCookie opaca con HttpOnlyCookie u otro mecanismo de almacenamiento del cliente
Varias instanciasRequiere un almacén de sesiones compartidoLa verificación puede realizarse entre instancias
Mejor usoAplicación de navegador con una aplicación del lado del servidorClientes móviles, servicio a servicio, IoT o federados
Principal cargaDisponibilidad del almacén de sesionesDiseñ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:

SuperficiePolítica inicialMotivo
POST /login5 intentos por 15 minutos por IP y cuentaRalentiza el credential stuffing
POST /tasks30 solicitudes por minuto por cuenta autenticadaLimita el abuso automatizado de escritura
GET /tasks120 solicitudes por minuto por cuenta autenticadaDeja margen para las actualizaciones del dashboard
Formularios públicosAjustar por encima de la actividad normal del usuarioEvita 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 conHelmet no puede solucionar
Varias cabeceras de seguridad del navegadorAutorización ausente
Protecciones contra framing y tipos de contenidoSQL inseguro
Política base del navegadorSecretos 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 .env a .gitignore antes del primer commit.
  • Confirma .env.example con 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:

  1. Elimina las credenciales codificadas directamente.
  2. Usa un archivo de entorno local ignorado por git.
  3. Carga los valores de producción desde un almacén de secretos gestionado.
  4. Inyéctalos en tiempo de ejecución mediante identidades con privilegios mínimos.
  5. 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, Secure y un SameSite apropiado 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.