🔐Ciberseguridad

Cómo proteger una API REST en Node.js: controles esenciales

Felipe••18 min de lectura
Protege una API REST con Express: autenticación, permisos por recurso, validación, rate limiting, CORS, secretos y logs, con ejemplos y referencias OWASP.

Una API puede usar HTTPS, aceptar JWT y tener CORS configurado, y aun así permitir que una persona modifique documentos de otra. El problema aparece cuando tratamos esos controles como si respondieran a la misma pregunta.

Vamos a proteger una operación concreta: cambiar el título de un documento privado mediante PATCH /documents/:id. Solo su propietario podrá hacerlo, con un permiso de escritura y una entrada válida. Esa regla nos permitirá conectar autenticación, autorización, validación y exposición de datos sin construir un proyecto completo.

Usaremos snippets de Express 5, con jose 6, Zod 4, express-rate-limit 8 y el middleware cors. Son fragmentos de integración, no una aplicación lista para arrancar: presuponen un proveedor de identidad configurado y un pool de PostgreSQL llamado pool. Los dominios example.com representan configuración de ejemplo, no servicios disponibles.

Antes del middleware: define qué quieres proteger

Una petición puede ser válida sintácticamente y seguir siendo peligrosa para el negocio. Necesitamos conocer el recurso, el actor y la operación permitida.

PreguntaControl principalRiesgo relacionado
¿Quién presenta la petición?AutenticaciónAPI2: Broken Authentication
¿Puede usar esta operación?Permisos por funciónAPI5: Broken Function Level Authorization
¿Puede modificar este documento?Autorización por recursoAPI1: Broken Object Level Authorization
¿Qué campos puede cambiar o leer?Autorización por propiedadAPI3: Broken Object Property Level Authorization
¿Cuánto trabajo puede provocar?Límites de consumoAPI4: Unrestricted Resource Consumption

Estos nombres corresponden a OWASP API Security Top 10, edición 2023. Es una referencia para reconocer riesgos; no una certificación de que una API sea segura por instalar determinadas bibliotecas. OWASP: riesgos de seguridad de API.

Nuestro recorrido de decisión será:

flowchart TD
    A[Solicitud] --> B[TLS y límite de tráfico]
    B --> C[Verificar el token]
    C --> D[Comprobar permiso de escritura]
    D --> E[Validar identificador y cuerpo]
    E --> F[Comprobar propietario al actualizar]
    F --> G[Devolver solo campos permitidos]

Cada comprobación puede detener la petición. El diagrama resume nuestro diseño; no prescribe una arquitectura universal ni convierte CORS en un control de identidad.

1. Autenticar significa verificar una credencial

Un token recibido en Authorization sigue siendo entrada no confiable. Leer su payload no demuestra quién lo emitió, para qué API sirve ni si ha expirado. OWASP incluye la ausencia de estas comprobaciones entre los problemas de autenticación. API2: Broken Authentication.

Para este ejemplo acordamos un contrato concreto con el proveedor:

  • Emisor y audiencia conocidos por el servidor.
  • Access tokens JWT firmados con RS256 y tipo at+jwt.
  • sub identifica al sujeto; exp indica la expiración; iat, la emisión.
  • scope es una cadena de permisos separados por espacios.

Ese formato no es universal. Adáptalo al contrato documentado de tu proveedor; no amplíes algoritmos ni tipos solo para que un token inesperado pase. Un ID token no se convierte en access token porque sea un JWT firmado. Para profundizar, revisa JWT: decodificar no es validar.

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Configuración del proveedor de confianza, no datos enviados por el cliente.
const issuer = 'https://auth.example.com/';
const audience = 'https://api.example.com';
const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
  { timeoutDuration: 3_000 },
);

const invalidTokenCodes = new Set([
  'ERR_JWT_EXPIRED',
  'ERR_JWT_CLAIM_VALIDATION_FAILED',
  'ERR_JWT_INVALID',
  'ERR_JWS_INVALID',
  'ERR_JWS_SIGNATURE_VERIFICATION_FAILED',
  'ERR_JOSE_ALG_NOT_ALLOWED',
  'ERR_JOSE_NOT_SUPPORTED',
  'ERR_JWKS_NO_MATCHING_KEY',
]);

function unauthorized(res) {
  return res
    .set('WWW-Authenticate', 'Bearer realm="documents"')
    .status(401)
    .json({ error: 'unauthorized' });
}

async function authenticate(req, res, next) {
  const match = /^Bearer ([^\s]+)$/i.exec(req.get('authorization') ?? '');

  if (!match) return unauthorized(res);

  try {
    const { payload } = await jwtVerify(match[1], JWKS, {
      issuer,
      audience,
      algorithms: ['RS256'],
      typ: 'at+jwt',
      requiredClaims: ['sub', 'exp', 'iat'],
    });

    if (typeof payload.sub !== 'string' || !payload.sub ||
        typeof payload.scope !== 'string') {
      return unauthorized(res);
    }

    res.locals.auth = {
      issuer,
      subject: payload.sub,
      scopes: new Set(payload.scope.split(' ').filter(Boolean)),
    };

    next();
  } catch (error) {
    if (invalidTokenCodes.has(error.code)) return unauthorized(res);

    // Un fallo del proveedor o de configuración no demuestra un token inválido.
    next(error);
  }
}

JWKS es el conjunto de claves públicas del proveedor. Su URL se configura desde una fuente de confianza y se reutiliza; no se toma de un jku arbitrario incluido en el token. jwtVerify comprueba firma y claims según las opciones indicadas. Pedir exp como obligatorio evita aceptar un token sin vencimiento bajo nuestro contrato. jose: jwtVerify.

El catch distingue rechazos de credenciales de otros fallos. Una caída al consultar claves no debe convertirse en acceso permitido. Tampoco demuestra por sí sola que el token sea inválido: el error se deriva al manejador central.

Error frecuente: verificar la firma y olvidar la audiencia. Un token legítimo destinado a otro servicio no tiene por qué servir en esta API. Otro error es asumir que la validación local de un JWT consulta revocaciones: para deshabilitar accesos antes de exp necesitas una estrategia adicional, como comprobar el estado de la cuenta o usar un mecanismo de revocación acordado. OWASP REST Security: JWT.

2. Autorizar la operación y el recurso son decisiones distintas

Un permiso como documents:write habilita una clase de operación. No concede acceso a todos los documentos. Primero exigiremos ese permiso:

function requireScope(scope) {
  return (_req, res, next) => {
    if (!res.locals.auth.scopes.has(scope)) {
      return res.status(403).json({ error: 'forbidden' });
    }

    next();
  };
}

El middleware se ejecutará después de authenticate, que construye res.locals.auth a partir de un token verificado. En una ruta administrativa podría exigirse otro permiso o una política distinta; esconder el botón en el frontend no restringe el endpoint. API5: Broken Function Level Authorization.

La segunda decisión depende del documento. En nuestro modelo, cada uno tiene un propietario identificado por la pareja owner_issuer y owner_subject. La comprobación se hará en la consulta de actualización, no comparando el ID del documento con el ID del usuario.

Un UUID difícil de adivinar tampoco reemplaza esa regla: alguien puede obtener el identificador por un enlace, una respuesta anterior o un registro. El riesgo de acceder a objetos ajenos se conoce como BOLA, Broken Object Level Authorization. API1: autorización por objeto.

Si tu aplicación permite documentos compartidos, equipos o múltiples organizaciones, la política deberá incorporar esas relaciones y el contexto de organización validado. La regla «solo el propietario» sirve para este ejemplo; no describe todos los modelos de permisos.

3. Validar entradas es definir un contrato, no limpiar todo

La API aceptará un UUID como identificador y un único campo editable: title. No recibirá owner_subject, role, isAdmin ni columnas internas aunque el cliente las incluya.

import { z } from 'zod';

const documentIdSchema = z.uuid();
const updateDocumentSchema = z.strictObject({
  title: z.string().trim().min(1).max(160),
});

function validateUpdate(req, res, next) {
  const id = documentIdSchema.safeParse(req.params.id);
  const body = updateDocumentSchema.safeParse(req.body);

  if (!id.success || !body.success) {
    return res.status(400).json({ error: 'invalid_input' });
  }

  res.locals.input = { id: id.data, title: body.data.title };
  next();
}

z.strictObject rechaza propiedades desconocidas. Además de tipos, comprobamos longitud y un título no vacío después de quitar espacios exteriores. safeParse permite responder con un error controlado. Zod: definición de esquemas.

Hay que validar también query strings, parámetros, cabeceras relevantes y estructuras anidadas cuando existan. Los tipos de TypeScript ayudan al desarrollar, pero no validan automáticamente los bytes recibidos por HTTP.

Error frecuente: pasar directamente req.body a un ORM o hacer una asignación masiva sobre una entidad. Que el JSON sea válido no significa que el cliente tenga permiso para cambiar todos sus campos. OWASP agrupa esa escritura indebida y la exposición de propiedades bajo API3. API3: autorización por propiedad.

La validación tampoco sustituye consultas parametrizadas ni el escape contextual al mostrar datos en HTML. Un título puede contener caracteres que sean legítimos como texto y peligrosos si otra capa los interpreta como código.

4. Aplica la autorización donde accedes a los datos

Esta función recibe los datos ya validados y el sujeto autenticado. pool representa un pool de pg configurado al iniciar el servicio; la tabla documents contiene id, title, owner_issuer y owner_subject, además de posibles campos internos.

async function updateDocument(_req, res) {
  const { id, title } = res.locals.input;
  const { issuer, subject } = res.locals.auth;

  const result = await pool.query(
    `UPDATE documents
     SET title = $1
     WHERE id = $2
       AND owner_issuer = $3
       AND owner_subject = $4
     RETURNING id, title`,
    [title, id, issuer, subject],
  );

  const document = result.rows[0];

  if (!document) {
    return res.status(404).json({ error: 'not_found' });
  }

  res.json({ id: document.id, title: document.title });
}

El predicado del UPDATE incluye el recurso y su propietario. La modificación y esa comprobación se realizan en la misma sentencia. No basta con consultar el documento, comprobar algo en JavaScript y luego ejecutar una actualización sin las restricciones pertinentes.

Los valores se entregan por separado en $1, $2, etc. No se concatenan en el SQL. Los parámetros protegen los valores; no permiten interpolar con seguridad nombres de columnas o tablas arbitrarios. node-postgres: consultas parametrizadas.

En este diseño devolvemos 404 tanto si el documento no existe como si no pertenece al sujeto. Es una decisión de la API para no revelar su existencia en esa respuesta; no reemplaza la comprobación de permisos. Para una credencial ausente o inválida usamos 401; para un permiso de operación insuficiente, 403.

La respuesta también necesita un contrato

El cliente recibe solo id y title. Evitamos tanto RETURNING * como devolver una entidad completa con notas internas, identificadores de otras personas o datos administrativos.

Ocultar un campo en la interfaz no lo elimina de la respuesta de red. El filtrado debe ocurrir antes de serializar la respuesta. Un DTO —Data Transfer Object, objeto de transferencia de datos— es una forma de expresar ese contrato; aquí basta con construir un objeto pequeño explícitamente. API3: autorización por propiedad.

5. Limitar peticiones y limitar consumo son controles complementarios

Un límite por IP ayuda antes de verificar tokens; otro por identidad permite aplicar una cuota al sujeto ya autenticado. No uses el userId recibido en un header cualquiera como clave del limitador.

import { rateLimit } from 'express-rate-limit';

// Estos valores son ilustrativos y el store por defecto vive en un proceso.
const perIp = rateLimit({
  windowMs: 60_000,
  limit: 120,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'too_many_requests' },
});

const perUser = rateLimit({
  windowMs: 60_000,
  limit: 30,
  keyGenerator: (_req, res) => JSON.stringify([
    res.locals.auth.issuer,
    res.locals.auth.subject,
  ]),
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'too_many_requests' },
});

perUser debe ejecutarse después de authenticate. Las cantidades del snippet son ejemplos para explicar el mecanismo, no recomendaciones universales. Una red compartida puede reunir muchos usuarios bajo una IP; una misma cuenta puede conectarse desde varias IP.

La biblioteca devuelve 429 al superar el límite y comunica cuándo reintentar. Su almacén en memoria no coordina procesos ni persiste tras reiniciar: con varias réplicas necesitas un store compartido compatible o aplicar una cuota coherente en el gateway. express-rate-limit: configuración, límites del MemoryStore.

Aun con pocas solicitudes, una API puede agotar recursos si cada una dispara un informe enorme. Define también tamaño máximo del cuerpo, paginación limitada, tiempos de ejecución, concurrencia y presupuestos para servicios externos. El límite de JSON que veremos después no limita por sí solo CPU, consultas SQL ni llamadas a terceros. API4: consumo de recursos sin restricciones.

Para reservas, compras o envíos de mensajes, añade reglas del negocio. Un usuario autenticado que automatiza una operación permitida puede perjudicar el servicio sin vulnerar un esquema JSON. API6: acceso irrestricto a flujos sensibles.

6. CORS controla lecturas del navegador, no permisos de la API

CORS no sustituye la autenticación ni la autorización. Sus cabeceras permiten que el navegador decida si el JavaScript de otro origen puede leer una respuesta. No impiden que un programa como curl envíe peticiones y lea sus respuestas. Algunas peticiones del navegador también pueden llegar al servidor aunque su respuesta no quede accesible al JavaScript que las originó. Express: funcionamiento de CORS.

Para nuestro frontend concreto:

import cors from 'cors';

const browserAccess = cors({
  origin: 'https://app.example.com',
  methods: ['PATCH', 'OPTIONS'],
  allowedHeaders: ['Authorization', 'Content-Type'],
  credentials: false,
});

Esta configuración corresponde a una API que recibe un bearer token mediante Authorization y no usa cookies de sesión. No refleja automáticamente cualquier Origin. El middleware atiende el preflight, la consulta OPTIONS que el navegador puede realizar antes de una petición, antes de exigir autenticación a la operación real.

Un cliente fuera del navegador sigue necesitando un token válido y permisos. También puede enviar un Origin inventado: esa cabecera no prueba su identidad.

Si tu autenticación usa cookies, debes revisar credentials, orígenes explícitos, SameSite, Secure y protección contra CSRF —peticiones inducidas desde otro sitio usando credenciales que el navegador envía automáticamente—. Copiar la configuración de una API con bearer tokens no resuelve ese modelo. OWASP REST Security.

7. Los secretos necesitan un ciclo de vida

Las credenciales de la base de datos, tokens de servicios externos y claves privadas no deben estar en Git, imágenes de contenedor públicas, bundles del navegador ni logs. Las URL de emisor y JWKS del ejemplo son configuración pública; no son secretos equivalentes a una contraseña.

Una variable de entorno es un mecanismo para entregar configuración, no una garantía de cifrado ni de aislamiento. Usa el gestor de secretos o mecanismo protegido del despliegue, con permisos mínimos, separación por entorno y capacidad de rotar y revocar credenciales. Si un secreto se filtra, quitarlo del archivo no invalida la copia robada. OWASP: gestión de secretos.

Como mínimo, falla al arrancar si falta un valor obligatorio:

function requiredEnv(name) {
  const value = process.env[name];

  if (!value?.trim()) {
    throw new Error(`Falta configuración obligatoria: ${name}`);
  }

  return value;
}

// Valor inyectado en el despliegue; nunca imprimirlo ni devolverlo al cliente.
const databaseURL = requiredEnv('DATABASE_URL');

databaseURL se usaría al configurar el pool. Evita alternativas como una contraseña de desarrollo por defecto en producción. Un .env.example puede documentar los nombres requeridos con valores vacíos; el archivo real con credenciales no debe versionarse.

8. Errores y logs: informar sin filtrar

Una respuesta de error no debe revelar SQL, rutas del servidor, tokens ni stack traces. Pero tampoco conviene convertir todos los fallos en 200 o borrar las señales que necesitas para investigar.

Usaremos un identificador generado por el servidor para relacionar la respuesta con los registros. El log será estructurado y contendrá una lista pequeña de campos. No registraremos req, req.body, Authorization, cookies ni la URL completa con su query string. OWASP: datos que deben excluirse de los logs.

import { randomUUID } from 'node:crypto';

function requestContext(req, res, next) {
  const requestId = randomUUID();
  const startedAt = performance.now();

  res.locals.requestId = requestId;
  res.set('X-Request-ID', requestId);
  res.set('Cache-Control', 'no-store');

  res.on('finish', () => {
    console.info(JSON.stringify({
      event: 'http_request',
      requestId,
      method: req.method,
      route: req.route?.path ?? 'unmatched',
      status: res.statusCode,
      durationMs: Math.round(performance.now() - startedAt),
    }));
  });

  next();
}

function errorHandler(error, _req, res, next) {
  if (res.headersSent) return next(error);

  let status = 500;
  let code = 'internal_error';

  if (error.type === 'entity.parse.failed') {
    status = 400;
    code = 'invalid_json';
  } else if (error.type === 'entity.too.large') {
    status = 413;
    code = 'payload_too_large';
  } else if (error.type === 'encoding.unsupported' ||
             error.type === 'charset.unsupported') {
    status = 415;
    code = 'unsupported_encoding';
  } else if (error.code === 'ERR_JWKS_TIMEOUT') {
    status = 503;
    code = 'authentication_unavailable';
  }

  console.error(JSON.stringify({
    event: 'request_failed',
    requestId: res.locals.requestId,
    code,
  }));

  res.status(status).json({ error: code, requestId: res.locals.requestId });
}

req.route.path identifica la plantilla de la ruta, como /documents/:id, en lugar del identificador concreto enviado en la URL. La salida JSON evita concatenar texto libre en líneas de log. Esto no elimina la necesidad de controlar acceso, retención, volumen y transporte de los registros.

El ejemplo registra códigos controlados y no serializa la excepción completa. En una aplicación real puedes añadir diagnóstico interno restringido, pero revisa y redacta datos sensibles antes de enviarlo a un sistema de observabilidad. Un stack trace o una excepción de base de datos también pueden contener secretos.

En Express 5, los rechazos de promesas devueltas por handlers async se derivan al middleware de errores. Ese manejador debe conservar sus cuatro parámetros y colocarse después de las rutas. Express: manejo de errores.

Error frecuente: responder con error.message para cualquier excepción. Aunque resulte cómodo en desarrollo, su contenido no constituye un contrato público seguro.

9. TLS protege el transporte; el proxy también forma parte del diseño

Publica la API por HTTPS. TLS protege la confidencialidad e integridad en tránsito y permite verificar al servidor; no decide si el sujeto puede modificar un documento. La guía de OWASP recomienda TLS 1.3 y contempla TLS 1.2 por compatibilidad, deshabilitando protocolos antiguos. Es una recomendación técnica de esa guía, no una obligación legal universal. OWASP: TLS.

Cuando TLS termina en un balanceador, protege también el tramo hasta Node según la arquitectura y evita que una ruta directa al origen eluda los controles del gateway. Redirigir desde HTTP no deshace la exposición de un token que el cliente ya haya enviado sin cifrar. Tampoco desactives la validación de certificados para conectar con la base de datos o un proveedor externo.

En Express, trust proxy afecta a datos como req.ip. Configurarlo indiscriminadamente como true puede permitir confiar en cabeceras manipuladas; no configurarlo cuando sí hay un proxy puede agrupar a todos los usuarios bajo su IP. Define la confianza según los proxies y rutas reales, y exige que el proxy sobrescriba las cabeceras correspondientes. Express detrás de proxies.

10. El orden de los controles es parte de su implementación

Reuniendo las funciones anteriores, la operación queda así:

import express from 'express';
import helmet from 'helmet';

const app = express();
app.disable('x-powered-by');

app.use(requestContext);
app.use(helmet());
app.use(perIp);
app.use(browserAccess);

app.patch('/documents/:id',
  authenticate,
  perUser,
  requireScope('documents:write'),
  (req, res, next) => {
    if (!req.is('application/json')) {
      return res.status(415).json({ error: 'unsupported_media_type' });
    }

    next();
  },
  express.json({ limit: '16kb', inflate: false }),
  validateUpdate,
  updateDocument,
);

app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
app.use(errorHandler);

Este montaje supone que has definido las funciones de los snippets y configurado pool. No incluye un servidor de identidad, una base de datos ni el despliegue de TLS.

El orden tiene motivos concretos:

  • El contexto de petición permite registrar también los rechazos tempranos.
  • El límite por IP actúa antes del trabajo de verificación criptográfica.
  • CORS puede resolver el preflight; la petición PATCH real pasa por autenticación y permisos.
  • La cuota por sujeto usa una identidad ya verificada.
  • Solo se analiza JSON con el tipo esperado y un tamaño limitado; se rechaza la compresión en este endpoint para mantener explícito ese contrato.
  • La consulta combina autorización del recurso y actualización; la respuesta expone solo los campos elegidos.
  • Los errores no controlados llegan al manejador final.

helmet() configura cabeceras de seguridad. Conviene revisar cuáles corresponden al despliegue, especialmente si también sirves HTML; no añade TLS ni implementa autorización. Helmet: documentación oficial.

Errores que siguen siendo posibles fuera de estos snippets

Proteger este endpoint no protege automáticamente todas las integraciones de la aplicación:

  • Versiones antiguas y endpoints olvidados. Mantén un inventario de rutas, entornos y versiones; retira las que ya no deben estar disponibles. API9: gestión de inventario.
  • Descargar una URL enviada por el cliente. Puede introducir SSRF, es decir, inducir al servidor a consultar destinos no autorizados. Necesita una política de destinos, redirecciones y acceso de red, no solo validar que la cadena sea una URL. API7: SSRF.
  • Confiar ciegamente en otro servicio. Valida también sus respuestas, limita tamaños y tiempos, y revisa redirecciones y transporte. API10: consumo inseguro de API.
  • Configuración o dependencias abandonadas. Revisa versiones mantenidas, opciones de depuración, permisos del proceso y acceso a paneles internos. Express: seguridad en producción, API8: configuración de seguridad incorrecta.

Comprueba los rechazos, no solo el caso exitoso

Estas pruebas expresan el contrato de nuestra operación:

CasoResultado esperado
Token ausente, expirado o destinado a otra API401, sin actualizar
Token válido sin documents:write403, sin actualizar
Documento de otro propietario404, sin datos del documento
Campo adicional como owner_subject400, sin asignación masiva
JSON por encima del límite413
Cuota superada429 y posibilidad de reintentar según la política
Excepción internaError controlado sin SQL, secretos ni stack
Operación permitidaSolo los campos públicos definidos

Además de comprobar códigos HTTP, verifica los efectos: que no haya escrituras indebidas, que los logs no contengan credenciales y que los límites sigan funcionando al desplegar varias instancias. Una respuesta 403 después de haber modificado el dato no cumple el objetivo.

Diseña cada endpoint como una decisión de acceso

Un control útil responde una pregunta concreta y falla de forma previsible. La autenticación establece el sujeto; la autorización determina qué puede hacer sobre un recurso; la validación acota los datos; los límites contienen el consumo; y el transporte, los errores y los logs reducen exposiciones adicionales.

Ese criterio permite revisar una API ruta por ruta. Cuando añadas una operación nueva, define sus permisos, entradas, respuestas y coste antes de confiar en que el middleware global ya la protege.

Referencias

#seguridad API REST#Node.js#Express#OWASP API Security#autorización#validación#CORS#rate limiting#ciberseguridad

Artículos relacionados