Saltar al contenido
·34 min de lectura

Mejores Prácticas de Seguridad en APIs: La Lista de Verificación que Ejecuto en Cada Proyecto

Autenticación, autorización, validación de entradas, limitación de velocidad, CORS, gestión de secretos y el OWASP API Top 10. Lo que reviso antes de cada despliegue a producción.

Compartir:X / TwitterLinkedIn

He desplegado APIs completamente abiertas. No por malicia, no por pereza — simplemente no sabía lo que no sabía. Un endpoint que devolvía cada campo del objeto usuario, incluyendo contraseñas hasheadas. Un limitador de velocidad que solo verificaba direcciones IP, lo que significaba que cualquiera detrás de un proxy podía bombardear la API. Una implementación JWT donde olvidé verificar el claim iss, así que tokens de un servicio completamente diferente funcionaban sin problemas.

Cada uno de esos errores llegó a producción. Todos fueron descubiertos — algunos por mí, algunos por usuarios, uno por un investigador de seguridad que tuvo la amabilidad de enviarme un correo electrónico en vez de publicarlo en Twitter.

Este artículo es la lista de verificación que construí a partir de esos errores. La ejecuto antes de cada despliegue a producción. No porque sea paranoico, sino porque aprendí que los bugs de seguridad son los que más duelen. Un botón roto molesta a los usuarios. Un flujo de autenticación roto filtra sus datos.

Autenticación vs Autorización#

Estas dos palabras se usan indistintamente en reuniones, en documentación, incluso en comentarios de código. No son lo mismo.

Autenticación responde: "¿Quién eres?" Es el paso de inicio de sesión. Nombre de usuario y contraseña, flujo OAuth, enlace mágico — lo que sea que demuestre tu identidad.

Autorización responde: "¿Qué tienes permitido hacer?" Es el paso de permisos. ¿Puede este usuario eliminar este recurso? ¿Puede acceder a este endpoint de administración? ¿Puede leer los datos de otro usuario?

El bug de seguridad más común que he visto en APIs de producción no es un flujo de inicio de sesión roto. Es una verificación de autorización faltante. El usuario está autenticado — tiene un token válido — pero la API nunca verifica si tiene permiso para realizar la acción que está solicitando.

JWT: Anatomía y los Errores que Importan#

Los JWTs están en todas partes. También se malinterpretan en todas partes. Un JWT tiene tres partes, separadas por puntos:

header.payload.signature

El header indica qué algoritmo se usó. El payload contiene claims (ID de usuario, roles, expiración). La firma demuestra que nadie manipuló las dos primeras partes.

Aquí tienes una verificación JWT adecuada en Node.js:

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // Nunca permitir "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 segundos de tolerancia por desfase de reloj
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expirado");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Token inválido");
    }
    throw new ApiError(401, "Autenticación fallida");
  }
}

Algunas cosas a notar:

  1. algorithms: ["HS256"] — Esto es crítico. Si no especificas el algoritmo, un atacante puede enviar un token con "alg": "none" en el header y saltarse la verificación por completo. Este es el ataque alg: none, y ha afectado a sistemas reales en producción.

  2. issuer y audience — Sin estos, un token emitido para el Servicio A funciona en el Servicio B. Si ejecutas múltiples servicios que comparten el mismo secreto (lo cual no deberías hacer, pero la gente lo hace), así es como ocurre el abuso de tokens entre servicios.

  3. Manejo específico de errores — No devuelvas "token inválido" para cada fallo. Distinguir entre expirado e inválido ayuda al cliente a saber si debe refrescar o re-autenticarse.

Rotación de Tokens de Refresco#

Los tokens de acceso deben ser de corta duración — 15 minutos es el estándar. Pero no quieres que los usuarios vuelvan a ingresar su contraseña cada 15 minutos. Para eso sirven los tokens de refresco.

El patrón que realmente funciona en producción:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Familia de tokens para detección de rotación
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token no encontrado — ya expiró o ya fue usado.
    // Si ya fue usado, este es un posible ataque de repetición.
    // Invalidar toda la familia de tokens.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Token de refresco inválido");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Eliminar el token antiguo inmediatamente — uso único
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Generar nuevos tokens
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Almacenar el nuevo token de refresco con la misma familia
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 días
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Escanear todos los tokens de esta familia y eliminarlos.
  // Esta es la opción nuclear — si alguien repite un token de refresco,
  // eliminamos todos los tokens de la familia, forzando la re-autenticación.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

El concepto de familia de tokens es lo que hace esto seguro. Cada token de refresco pertenece a una familia (creada al iniciar sesión). Cuando rotas, el nuevo token hereda la familia. Si un atacante repite un token de refresco antiguo, detectas la reutilización y eliminas toda la familia. El usuario legítimo cierra sesión, pero el atacante no entra.

Este debate lleva años, y la respuesta es clara: cookies httpOnly para tokens de refresco, siempre.

localStorage es accesible para cualquier JavaScript que se ejecute en tu página. Si tienes una sola vulnerabilidad XSS — y a escala, eventualmente la tendrás — el atacante puede leer el token y exfiltrarlo. Se acabó el juego.

Las cookies httpOnly no son accesibles para JavaScript. Punto. Una vulnerabilidad XSS aún puede hacer solicitudes en nombre del usuario (porque las cookies se envían automáticamente), pero el atacante no puede robar el token en sí. Esa es una diferencia significativa.

typescript
// Configurar una cookie segura de token de refresco
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // No accesible mediante JavaScript
    secure: true,       // Solo HTTPS
    sameSite: "strict", // Sin solicitudes cross-site
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 días
    path: "/api/auth",  // Solo se envía a endpoints de autenticación
  });
}

El path: "/api/auth" es un detalle que la mayoría pasa por alto. Por defecto, las cookies se envían a cada endpoint en tu dominio. Tu token de refresco no necesita ir a /api/users o /api/products. Restringe la ruta, reduce la superficie de ataque.

Para tokens de acceso, los mantengo en memoria (una variable JavaScript). No en localStorage, no en sessionStorage, no en una cookie. En memoria. Son de corta duración (15 minutos), y cuando la página se recarga, el cliente silenciosamente contacta el endpoint de refresco para obtener uno nuevo. Sí, esto significa una solicitud extra al cargar la página. Vale la pena.

Validación de Entradas: Nunca Confíes en el Cliente#

El cliente no es tu amigo. El cliente es un extraño que entró a tu casa y dijo "Tengo permiso para estar aquí." Verificas su identificación de todas formas.

Cada dato que viene de fuera de tu servidor — cuerpo de la solicitud, parámetros de consulta, parámetros de URL, headers — es entrada no confiable. No importa que tu formulario React tenga validación. Alguien lo evitará con curl.

Zod para Validación con Seguridad de Tipos#

Zod es lo mejor que le ha pasado a la validación de entradas en Node.js. Te da validación en tiempo de ejecución con tipos de TypeScript gratis:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Formato de email inválido")
    .max(254, "Email demasiado largo")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "La contraseña debe tener al menos 12 caracteres")
    .max(128, "Contraseña demasiado larga") // Prevenir DoS de bcrypt
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "La contraseña debe contener mayúsculas, minúsculas y un número"
    ),
 
  name: z
    .string()
    .min(1, "El nombre es obligatorio")
    .max(100, "Nombre demasiado largo")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "El nombre contiene caracteres inválidos"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Nota: "admin" no es intencionalmente una opción aquí.
  // La asignación del rol de admin va a través de un endpoint separado y privilegiado.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Uso en un handler de Express
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validación fallida",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data tiene tipos completos como CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Algunos detalles relevantes para la seguridad:

  • max(128) en contraseña — bcrypt tiene un límite de entrada de 72 bytes, y algunas implementaciones simplemente truncan en silencio. Pero más importante, si permites una contraseña de 10MB, bcrypt pasará un tiempo significativo hasheándola. Eso es un vector de DoS.
  • max(254) en email — El RFC 5321 limita las direcciones de email a 254 caracteres. Cualquier cosa más larga no es un email válido.
  • Enum para rol, sin admin — La asignación masiva es una de las vulnerabilidades API más antiguas. Si aceptas el rol del cuerpo de la solicitud sin validarlo, alguien enviará "role": "admin" y esperará lo mejor.

La Inyección SQL No Está Resuelta#

"Solo usa un ORM" no te protege si escribes consultas raw por rendimiento. Y todo el mundo escribe consultas raw por rendimiento eventualmente.

typescript
// VULNERABLE — concatenación de cadenas
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// SEGURO — consulta parametrizada
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Con Prisma, estás mayormente a salvo — pero $queryRaw aún puede morderte:

typescript
// VULNERABLE — literal de plantilla en $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// SEGURO — usando Prisma.sql para parametrización
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

Inyección NoSQL#

MongoDB no usa SQL, pero no es inmune a la inyección. Si pasas entrada de usuario sin sanitizar como un objeto de consulta, las cosas salen mal:

typescript
// VULNERABLE — si req.body.username es { "$gt": "" }
// esto devuelve el primer usuario de la colección
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// SEGURO — convertir explícitamente a cadena
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// MEJOR — validar con Zod primero
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

La solución es simple: valida los tipos de entrada antes de que lleguen a tu driver de base de datos. Si username debe ser una cadena, asegúrate de que sea una cadena.

Traversal de Rutas#

Si tu API sirve archivos o lee desde una ruta que incluye entrada del usuario, el traversal de rutas arruinará tu semana:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalizar y resolver a una ruta absoluta
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Crítico: verificar que la ruta resuelta todavía esté dentro del directorio permitido
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Acceso denegado");
  }
 
  // Verificar que el archivo realmente existe
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Sin esta verificación:
// GET /api/files?name=../../../etc/passwd
// se resuelve a /etc/passwd

El patrón path.resolve + startsWith es el enfoque correcto. No intentes eliminar ../ manualmente — hay demasiados trucos de codificación (..%2F, ..%252F, ....//) que evadirán tu regex.

Limitación de Velocidad#

Sin limitación de velocidad, tu API es un buffet libre para bots. Ataques de fuerza bruta, relleno de credenciales, agotamiento de recursos — la limitación de velocidad es la primera defensa contra todos ellos.

Token Bucket vs Ventana Deslizante#

Token bucket: Tienes un cubo que contiene N tokens. Cada solicitud cuesta un token. Los tokens se rellenan a una tasa fija. Si el cubo está vacío, la solicitud es rechazada. Esto permite ráfagas — si el cubo está lleno, puedes hacer N solicitudes instantáneamente.

Ventana deslizante: Cuenta solicitudes dentro de una ventana de tiempo móvil. Más predecible, más difícil de atravesar con ráfagas.

Yo uso ventana deslizante para la mayoría de cosas porque el comportamiento es más fácil de razonar y explicar al equipo:

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // Eliminar entradas fuera de la ventana
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Contar entradas en la ventana
  multi.zcard(key);
 
  // Agregar la solicitud actual (la eliminaremos si excede el límite)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Establecer expiración en la clave
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Transacción de Redis falló");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Por encima del límite — eliminar la entrada que acabamos de agregar
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Límites de Velocidad por Capas#

Un único límite de velocidad global no es suficiente. Diferentes endpoints tienen diferentes perfiles de riesgo:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Endpoints de autenticación — límites estrictos, objetivo de fuerza bruta
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // Lecturas de datos — más generoso
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Escrituras de datos — moderado
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Respaldo global
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

Observa: los usuarios autenticados se limitan por ID de usuario, no por IP. Esto es importante porque muchos usuarios legítimos comparten IPs (redes corporativas, VPNs, operadores móviles). Si solo limitas por IP, bloquearás oficinas enteras.

Headers de Límite de Velocidad#

Siempre informa al cliente de lo que está pasando:

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "Demasiadas solicitudes",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

Configuración de CORS#

CORS es probablemente el mecanismo de seguridad más malentendido en el desarrollo web. La mitad de las respuestas de Stack Overflow sobre CORS son "solo configura Access-Control-Allow-Origin: * y funciona." Eso es técnicamente cierto. También es cómo abres tu API a cada sitio malicioso en internet.

Lo que CORS Realmente Hace (y No Hace)#

CORS es un mecanismo del navegador. Le dice al navegador si JavaScript del Origen A tiene permitido leer la respuesta del Origen B. Eso es todo.

Lo que CORS no hace:

  • No protege tu API de curl, Postman, o solicitudes servidor-a-servidor
  • No autentica solicitudes
  • No cifra nada
  • No previene CSRF por sí solo (aunque ayuda cuando se combina con otros mecanismos)

Lo que CORS sí hace:

  • Previene que sitio-malicioso.com haga solicitudes fetch a tu-api.com y lea la respuesta en el navegador del usuario
  • Previene que el JavaScript del atacante exfiltre datos a través de la sesión autenticada de la víctima

La Trampa del Comodín#

typescript
// PELIGROSO — permite que cualquier sitio web lea tus respuestas API
app.use(cors({ origin: "*" }));
 
// TAMBIÉN PELIGROSO — este es un enfoque "dinámico" común que es solo * con pasos extra
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Permite todo
    },
  })
);

El problema con * es que hace que las respuestas de tu API sean legibles por cualquier JavaScript en cualquier página. Si tu API devuelve datos de usuario y el usuario está autenticado mediante cookies, cualquier sitio web que el usuario visite puede leer esos datos.

Aún peor: Access-Control-Allow-Origin: * no puede combinarse con credentials: true. Así que si necesitas cookies (para autenticación), literalmente no puedes usar el comodín. Pero he visto gente intentar evadir esto reflejando el header Origin de vuelta — lo cual es equivalente a * con credenciales, lo peor de ambos mundos.

La Configuración Correcta#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Permitir solicitudes sin origen (apps móviles, curl, servidor-a-servidor)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origen ${origin} no permitido por CORS`));
    },
    credentials: true, // Permitir cookies
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cachear preflight por 24 horas
  })
);

Decisiones clave:

  • Conjunto explícito de orígenes, no una regex. Las regex son engañosas — yourapp.com podría coincidir con evilyourapp.com si tu regex no está correctamente anclada.
  • credentials: true porque usamos cookies httpOnly para tokens de refresco.
  • maxAge: 86400 — Las solicitudes preflight (OPTIONS) añaden latencia. Decirle al navegador que cachee el resultado CORS por 24 horas reduce los viajes de ida y vuelta innecesarios.
  • exposedHeaders — Por defecto, el navegador solo expone un puñado de headers de respuesta "simples" a JavaScript. Si quieres que el cliente lea tus headers de límite de velocidad, debes exponerlos explícitamente.

Solicitudes Preflight#

Cuando una solicitud no es "simple" (usa un header no estándar, un método no estándar, o un tipo de contenido no estándar), el navegador envía primero una solicitud OPTIONS para pedir permiso. Esto es el preflight.

Si tu configuración CORS no maneja OPTIONS, las solicitudes preflight fallarán, y la solicitud real nunca se enviará. La mayoría de las bibliotecas CORS manejan esto automáticamente, pero si estás usando un framework que no lo hace, necesitas manejarlo:

typescript
// Manejo manual de preflight (la mayoría de los frameworks lo hacen por ti)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

Headers de Seguridad#

Los headers de seguridad son la mejora de seguridad más barata que puedes hacer. Son headers de respuesta que le dicen al navegador que active funciones de seguridad. La mayoría son una sola línea de configuración, y protegen contra clases enteras de ataques.

Los Headers que Importan#

typescript
import helmet from "helmet";
 
// Una línea. Esta es la mejora de seguridad más rápida en cualquier app Express.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Necesario para muchas soluciones CSS-in-JS
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 año
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Qué hace cada header:

Content-Security-Policy (CSP) — El header de seguridad más poderoso. Le dice al navegador exactamente qué fuentes están permitidas para scripts, estilos, imágenes, fuentes, etc. Si un atacante inyecta una etiqueta <script> que carga desde evil.com, CSP la bloquea. Esta es la defensa más efectiva contra XSS.

Strict-Transport-Security (HSTS) — Le dice al navegador que siempre use HTTPS, incluso si el usuario escribe http://. La directiva preload te permite enviar tu dominio a la lista HSTS incorporada del navegador, para que incluso la primera solicitud sea forzada a HTTPS.

X-Frame-Options — Previene que tu sitio sea incrustado en un iframe. Esto detiene los ataques de clickjacking donde un atacante superpone tu página con elementos invisibles. Helmet lo configura como SAMEORIGIN por defecto. El reemplazo moderno es frame-ancestors en CSP.

X-Content-Type-Options: nosniff — Previene que el navegador adivine (olfatee) el tipo MIME de una respuesta. Sin esto, si sirves un archivo con el Content-Type incorrecto, el navegador podría ejecutarlo como JavaScript.

Referrer-Policy — Controla cuánta información de URL se envía en el header Referer. strict-origin-when-cross-origin envía la URL completa para solicitudes del mismo origen pero solo el origen para solicitudes cross-origin. Esto previene la filtración de parámetros URL sensibles a terceros.

Probando tus Headers#

Después de desplegar, verifica tu puntuación en securityheaders.com. Apunta a una calificación A+. Toma unos cinco minutos de configuración para llegar ahí.

También puedes verificar los headers programáticamente:

typescript
import { describe, it, expect } from "vitest";
 
describe("Headers de seguridad", () => {
  it("debería incluir todos los headers de seguridad requeridos", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet lo elimina
  });
});

La verificación de x-powered-by es sutil pero importante. Express establece X-Powered-By: Express por defecto, diciéndole a los atacantes exactamente qué framework estás usando. Helmet lo elimina.

Gestión de Secretos#

Esto debería ser obvio, pero aún lo veo en pull requests: claves API, contraseñas de base de datos y secretos JWT escritos directamente en archivos fuente. O commiteados en archivos .env que no estaban en .gitignore. Una vez que está en el historial de git, está ahí para siempre, incluso si eliminas el archivo en el siguiente commit.

Las Reglas#

  1. Nunca commitees secretos a git. No en código, no en .env, no en archivos de configuración, no en archivos Docker Compose, no en comentarios "solo para pruebas".

  2. Usa .env.example como plantilla. Documenta qué variables de entorno se necesitan, sin contener valores reales:

bash
# .env.example — commitea esto
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — NUNCA commitees esto
# Listado en .gitignore
  1. Valida las variables de entorno al inicio. No esperes hasta que una solicitud llegue a un endpoint que necesite la URL de la base de datos. Falla rápido:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "El secreto JWT debe tener al menos 32 caracteres"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Variables de entorno inválidas:");
    console.error(result.error.format());
    process.exit(1); // No arrancar con configuración mala
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Usa un gestor de secretos en producción. Las variables de entorno funcionan para configuraciones simples, pero tienen limitaciones: son visibles en listados de procesos, persisten en memoria y pueden filtrarse a través de logs de error.

Para sistemas de producción, usa un gestor de secretos apropiado:

  • AWS Secrets Manager o SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (si quieres algo que funcione en todas las nubes)

El patrón es el mismo independientemente de cuál uses: la aplicación obtiene los secretos al inicio desde el gestor de secretos, no desde variables de entorno.

  1. Rota los secretos regularmente. Si has estado usando el mismo secreto JWT por dos años, es hora de rotar. Implementa rotación de claves: soporta múltiples claves de firma válidas simultáneamente, firma nuevos tokens con la nueva clave, verifica con la antigua y la nueva, y retira la clave antigua después de que todos los tokens existentes expiren.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Solo la clave activa firma nuevos tokens
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Devuelve todas las claves válidas
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Probar la siguiente clave
    }
  }
 
  throw new ApiError(401, "Token inválido");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Incluir ID de clave en el header
  });
}

OWASP API Security Top 10#

El OWASP API Security Top 10 es la lista estándar de la industria de vulnerabilidades API. Se actualiza periódicamente, y cada elemento en la lista es algo que he visto en bases de código reales. Permíteme repasar cada uno.

API1: Autorización a Nivel de Objeto Rota (BOLA)#

La vulnerabilidad API más común. El usuario está autenticado, pero la API no verifica si tiene acceso al objeto específico que está solicitando.

typescript
// VULNERABLE — cualquier usuario autenticado puede acceder a los datos de cualquier usuario
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// CORREGIDO — verificar que el usuario está accediendo a sus propios datos (o es admin)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Acceso denegado" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

La versión vulnerable está en todas partes. Pasa cada verificación de autenticación — el usuario tiene un token válido — pero no verifica si está autorizado para acceder a este recurso específico. Cambia el ID en la URL, y obtienes los datos de otra persona.

API2: Autenticación Rota#

Mecanismos de inicio de sesión débiles, MFA faltante, tokens que nunca expiran, contraseñas almacenadas en texto plano. Esto cubre la capa de autenticación en sí.

La solución es todo lo que discutimos en la sección de autenticación: requisitos fuertes de contraseña, bcrypt con rondas suficientes, tokens de acceso de corta duración, rotación de tokens de refresco, bloqueo de cuenta después de intentos fallidos.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutos
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `Cuenta bloqueada. Inténtelo de nuevo en ${Math.ceil(ttl / 60000)} minutos.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Incrementar intentos fallidos
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Mismo mensaje de error para ambos casos — no revelar si el email existe
    throw new ApiError(401, "Email o contraseña inválidos");
  }
 
  // Reiniciar intentos fallidos en inicio de sesión exitoso
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

El comentario sobre "mismo mensaje de error" es importante. Si tu API devuelve "usuario no encontrado" para emails inválidos y "contraseña incorrecta" para emails válidos con contraseñas incorrectas, le estás diciendo a un atacante qué emails existen en tu sistema.

API3: Autorización a Nivel de Propiedad de Objeto Rota#

Devolver más datos de lo necesario, o permitir que los usuarios modifiquen propiedades que no deberían.

typescript
// VULNERABLE — devuelve el objeto usuario completo, incluyendo campos internos
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // La respuesta incluye: passwordHash, internalNotes, billingId, ...
});
 
// CORREGIDO — lista blanca explícita de campos devueltos
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

Nunca devuelvas objetos completos de la base de datos. Siempre selecciona los campos que quieres exponer. Esto también aplica a las escrituras — no hagas spread del cuerpo completo de la solicitud en tu consulta de actualización:

typescript
// VULNERABLE — asignación masiva
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // El atacante envía: { "role": "admin", "verified": true }
});
 
// CORREGIDO — seleccionar campos permitidos
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4: Consumo Irrestricto de Recursos#

Tu API es un recurso. CPU, memoria, ancho de banda, conexiones a base de datos — todos son finitos. Sin límites, un solo cliente puede agotarlos todos.

Esto va más allá de la limitación de velocidad. Incluye:

typescript
// Limitar el tamaño del cuerpo de la solicitud
app.use(express.json({ limit: "1mb" }));
 
// Limitar la complejidad de las consultas
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// Limitar el tamaño de subida de archivos
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Tipo de archivo inválido"));
    }
  },
});
 
// Timeout para solicitudes de larga duración
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Tiempo de solicitud agotado" });
  });
  next();
});

API5: Autorización a Nivel de Función Rota#

Diferente de BOLA. Esto se trata de acceder a funciones (endpoints) a las que no deberías tener acceso, no objetos. El ejemplo clásico: un usuario regular descubriendo endpoints de administración.

typescript
// Middleware que verifica acceso basado en roles
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "No autenticado" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Registrar el intento — esto podría ser un ataque
      logger.warn("Intento de acceso no autorizado", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Permisos insuficientes" });
    }
 
    next();
  };
}
 
// Aplicar a las rutas
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

No confíes en ocultar endpoints. "Seguridad por oscuridad" no es seguridad. Incluso si la URL del panel de administración no está enlazada en ningún lugar, alguien encontrará /api/admin/users haciendo fuzzing.

API6: Acceso Irrestricto a Flujos de Negocio Sensibles#

Abuso automatizado de funcionalidad de negocio legítima. Piensa en: bots comprando artículos de stock limitado, creación automatizada de cuentas para spam, scraping de precios de productos.

Las mitigaciones son específicas del contexto: CAPTCHAs, huellas digitales de dispositivos, análisis de comportamiento, autenticación reforzada para operaciones sensibles. No hay un fragmento de código único para todos.

API7: Falsificación de Solicitudes del Lado del Servidor (SSRF)#

Si tu API obtiene URLs proporcionadas por el usuario (webhooks, URLs de fotos de perfil, vistas previas de enlaces), un atacante puede hacer que tu servidor solicite recursos internos:

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "URL inválida");
  }
 
  // Solo permitir HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Solo se permiten URLs HTTP(S)");
  }
 
  // Resolver el hostname y verificar si es una IP privada
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Las direcciones internas no están permitidas");
    }
  }
 
  // Ahora obtener con timeout y límite de tamaño
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // No seguir redirecciones (podrían redirigir a IPs internas)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Detalles clave: resuelve el DNS primero y verifica la IP antes de hacer la solicitud. Bloquea las redirecciones — un atacante puede alojar una URL que redirija a http://169.254.169.254/ (endpoint de metadatos de AWS) para evadir tu verificación a nivel de URL.

API8: Mala Configuración de Seguridad#

Credenciales por defecto sin cambiar, métodos HTTP innecesarios habilitados, mensajes de error verbosos en producción, listado de directorios habilitado, CORS mal configurado. Esta es la categoría de "olvidaste cerrar la puerta con llave".

typescript
// No filtrar stack traces en producción
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Error no manejado", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Mensaje de error genérico — no revelar internos
    res.status(500).json({
      error: "Error interno del servidor",
      requestId: req.id, // Incluir un ID de solicitud para depuración
    });
  } else {
    // En desarrollo, mostrar el error completo
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Deshabilitar métodos HTTP innecesarios
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Método no permitido" });
  }
  next();
});

API9: Gestión Inadecuada de Inventario#

Desplegaste la v2 de la API pero olvidaste apagar la v1. O hay un endpoint /debug/ que era útil durante el desarrollo y sigue ejecutándose en producción. O un servidor de staging que es accesible públicamente con datos de producción.

Esto no es una corrección de código — es disciplina de operaciones. Mantén una lista de todos los endpoints API, todas las versiones desplegadas y todos los entornos. Usa escaneo automatizado para encontrar servicios expuestos. Elimina lo que no necesites.

API10: Consumo Inseguro de APIs#

Tu API consume APIs de terceros. ¿Validas sus respuestas? ¿Qué pasa si un payload de webhook de Stripe en realidad proviene de un atacante?

typescript
import crypto from "crypto";
 
// Verificar firmas de webhook de Stripe
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // Rechazar timestamps antiguos (prevenir ataques de repetición)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // Tolerancia de 5 minutos
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Siempre verifica las firmas en los webhooks. Siempre valida la estructura de las respuestas de APIs de terceros. Siempre establece timeouts en las solicitudes salientes. Nunca confíes en datos solo porque vinieron de "un socio de confianza."

Registro de Auditoría#

Cuando algo sale mal — y lo hará — los logs de auditoría son cómo descubres qué pasó. Pero el registro es un arma de doble filo. Registra muy poco y estás ciego. Registra demasiado y creas una responsabilidad de privacidad.

Qué Registrar#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // Contexto adicional
  requestId: string;        // Para correlacionar con logs de la aplicación
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Escribir en un almacén de datos separado, solo de escritura
  // Esto NO debería ser la misma base de datos que usa tu aplicación
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Para acciones críticas, también escribir en un log externo inmutable
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Registra estos eventos:

  • Autenticación: inicios de sesión, cierres de sesión, intentos fallidos, refrescos de tokens
  • Autorización: eventos de acceso denegado (estos suelen ser indicadores de ataque)
  • Modificaciones de datos: creaciones, actualizaciones, eliminaciones — quién cambió qué, cuándo
  • Acciones de administración: cambios de roles, gestión de usuarios, cambios de configuración
  • Eventos de seguridad: activaciones de límites de velocidad, violaciones de CORS, solicitudes malformadas

Qué NO Registrar#

Nunca registres:

  • Contraseñas (ni siquiera las hasheadas — el hash es una credencial)
  • Números completos de tarjetas de crédito (registra solo los últimos 4 dígitos)
  • Números de Seguro Social o documentos de identidad gubernamentales
  • Claves API o tokens (registra un prefijo como máximo: sk_live_...abc)
  • Información personal de salud
  • Cuerpos completos de solicitudes que podrían contener PII
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTADO]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Logs a Prueba de Manipulación#

Si un atacante obtiene acceso a tu sistema, una de las primeras cosas que hará es modificar los logs para cubrir sus huellas. El registro a prueba de manipulación hace esto detectable:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // El hash inicial en la cadena
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// Para verificar la integridad de la cadena:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // La cadena está rota — los logs han sido manipulados
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

Este es el mismo concepto que una blockchain — el hash de cada entrada de log depende de la entrada anterior. Si alguien modifica o elimina una entrada, la cadena se rompe.

Seguridad de Dependencias#

Tu código podría ser seguro. Pero ¿qué hay de los 847 paquetes npm en tu node_modules? El problema de la cadena de suministro es real, y ha empeorado con los años.

npm audit Es lo Mínimo#

bash
# Ejecuta esto en CI, falla la build en vulnerabilidades altas/críticas
npm audit --audit-level=high
 
# Corrige lo que puede corregirse automáticamente
npm audit fix
 
# Mira lo que realmente estás incluyendo
npm ls --all

Pero npm audit tiene limitaciones. Solo verifica la base de datos de avisos de npm, y sus calificaciones de severidad no siempre son precisas. Agrega herramientas adicionales por capas:

Escaneo Automatizado de Dependencias#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Agrupar actualizaciones menores y de parches para reducir el ruido de PRs
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

El Lockfile Es una Herramienta de Seguridad#

Siempre commitea tu package-lock.json (o pnpm-lock.yaml, o yarn.lock). El lockfile fija versiones exactas de cada dependencia, incluyendo las transitivas. Sin él, npm install podría traer una versión diferente a la que probaste — y esa versión diferente podría estar comprometida.

bash
# En CI, usa ci en vez de install — respeta el lockfile estrictamente
npm ci

npm ci falla si el lockfile no coincide con package.json, en vez de actualizarlo silenciosamente. Esto atrapa casos donde alguien modificó package.json pero olvidó actualizar el lockfile.

Evalúa Antes de Instalar#

Antes de agregar una dependencia, pregunta:

  1. ¿Realmente necesito esto? ¿Puedo escribir esto en 20 líneas en vez de agregar un paquete?
  2. ¿Cuántas descargas tiene? Los conteos bajos de descargas no son necesariamente malos, pero significan menos ojos revisando el código.
  3. ¿Cuándo fue la última actualización? Un paquete que no se ha actualizado en 3 años podría tener vulnerabilidades sin parchar.
  4. ¿Cuántas dependencias trae? is-odd depende de is-number que depende de kind-of. Son tres paquetes para hacer algo que una línea de código puede hacer.
  5. ¿Quién lo mantiene? Un solo mantenedor es un único punto de compromiso.
typescript
// No necesitas un paquete para esto:
const isEven = (n: number): boolean => n % 2 === 0;
 
// O esto:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// O esto:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

La Lista de Verificación Pre-Despliegue#

Esta es la lista de verificación que realmente uso antes de cada despliegue a producción. No es exhaustiva — la seguridad nunca está "terminada" — pero atrapa los errores que más importan.

#VerificaciónCriterio de AprobaciónPrioridad
1AutenticaciónJWTs verificados con algoritmo, emisor y audiencia explícitos. Sin alg: none.Crítica
2Expiración de tokensLos tokens de acceso expiran en 15 min o menos. Los tokens de refresco rotan con cada uso.Crítica
3Almacenamiento de tokensTokens de refresco en cookies httpOnly seguras. Sin tokens en localStorage.Crítica
4Autorización en cada endpointCada endpoint de acceso a datos verifica permisos a nivel de objeto. BOLA probado.Crítica
5Validación de entradasTodas las entradas de usuario validadas con Zod o equivalente. Sin req.body crudo en consultas.Crítica
6Inyección SQL/NoSQLTodas las consultas a BD usan consultas parametrizadas o métodos ORM. Sin concatenación de cadenas.Crítica
7Limitación de velocidadEndpoints de auth: 5/15min. API general: 60/min. Headers de límite de velocidad devueltos.Alta
8CORSLista blanca explícita de orígenes. Sin comodín con credenciales. Preflight cacheado.Alta
9Headers de seguridadCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy todos presentes.Alta
10Manejo de erroresLos errores de producción devuelven mensajes genéricos. Sin stack traces, sin errores SQL expuestos.Alta
11SecretosSin secretos en código o historial git. .env en .gitignore. Validados al inicio.Crítica
12Dependenciasnpm audit limpio (sin alta/crítica). Lockfile commiteado. npm ci en CI.Alta
13Solo HTTPSHSTS habilitado con preload. HTTP redirige a HTTPS. Flag de cookie segura establecida.Crítica
14RegistroEventos de auth, acceso denegado y mutaciones de datos registrados. Sin PII en los logs.Media
15Límites de tamaño de solicitudBody parser limitado (1MB por defecto). Subidas de archivos limitadas. Paginación de consultas aplicada.Media
16Protección SSRFURLs proporcionadas por usuarios validadas. IPs privadas bloqueadas. Redirecciones deshabilitadas o validadas.Media
17Bloqueo de cuentaLos intentos de inicio de sesión fallidos activan bloqueo después de 5 intentos. Bloqueo registrado.Alta
18Verificación de webhooksTodos los webhooks entrantes verificados con firmas. Protección contra repetición vía timestamp.Alta
19Endpoints de administraciónControl de acceso basado en roles en todas las rutas de admin. Intentos registrados.Crítica
20Asignación masivaEndpoints de actualización usan esquema Zod con campos en lista blanca. Sin spread de body crudo.Alta

Mantengo esto como una plantilla de issue de GitHub. Antes de etiquetar un release, alguien del equipo tiene que verificar cada fila y firmar. No es glamuroso, pero funciona.

El Cambio de Mentalidad#

La seguridad no es una característica que agregas al final. No es un sprint que haces una vez al año. Es una forma de pensar sobre cada línea de código que escribes.

Cuando escribes un endpoint, piensa: "¿Qué pasa si alguien envía datos que no espero?" Cuando agregas un parámetro, piensa: "¿Qué pasa si alguien cambia esto por el ID de otra persona?" Cuando agregas una dependencia, piensa: "¿Qué pasa si este paquete se compromete el próximo martes?"

No atraparás todo. Nadie lo hace. Pero ejecutar esta lista de verificación — metódicamente, antes de cada despliegue — atrapa las cosas que más importan. Las victorias fáciles. Los agujeros obvios. Los errores que convierten un mal día en una filtración de datos.

Construye el hábito. Ejecuta la lista de verificación. Despliega con confianza.

Artículos relacionados