Plataforma SaaSFastAPI · PostgreSQL · AWS SES · AWS WAF · JWT

Cuatro capas contra un DDoS dirigido al endpoint de login

El problema

El endpoint de login no tenía ninguna defensa. Un ataque DDoS dirigido específicamente contra él — miles de peticiones por segundo — saturó el servidor explotando que cada intento ejecutaba una consulta a BD y un hash bcrypt deliberadamente lento.

El ataque reveló tres problemas a la vez:

  1. Sin rate limiting — peticiones ilimitadas desde cualquier IP
  2. Sin bloqueo por intentos — fuerza bruta viable
  3. Sin segundo factor — una contraseña comprometida equivalía a acceso total

La solución: cuatro capas

Capa 0 — WAF (infraestructura): primera línea antes de que el tráfico llegue a la aplicación. Absorbe el volumen bruto sin consumir recursos de la app.

Capa 1 — Rate limiting: antes de tocar la base de datos, se evalúa el historial de intentos. La decisión clave: los parámetros viven en BD, no en código.

python
def esta_bloqueado(session, usuario_id: str) -> bool:
    config = obtener_config(session)  # umbrales desde BD, modificables sin redesplegar
    ventana = now - timedelta(seconds=config.ventana_segundos)
    intentos = contar_fallidos(session, usuario_id, desde=ventana)

    if intentos >= config.max_intentos:
        ultimo = obtener_ultimo_intento(session, usuario_id)
        bloquear_hasta = ultimo + timedelta(minutes=config.bloqueo_minutos)
        return now < bloquear_hasta

    return False

Capa 2 — Registro de intentos: cada intento queda registrado. Cuando el bloqueo expira, los intentos se limpian para no bloquear indefinidamente.

Capa 3 — OTP por email: credenciales correctas no equivalen a acceso. Se genera un código de un solo uso, se invalidan los anteriores, y se envía vía SES de forma asíncrona para no bloquear el endpoint.

python
def generar_otp(session, usuario_id: str) -> str:
    # Solo puede haber un código activo — invalidar los anteriores
    invalidar_codigos_pendientes(session, usuario_id)

    codigo = f"{random.randint(0, 999999):06d}"
    guardar_codigo(session, usuario_id, codigo, expira_en=timedelta(minutes=5))
    return codigo

El flujo

Petición
    │
  [WAF] ← bloqueo perimetral
    │
  [Rate limiter] ← cortar antes de tocar BD
    │
  [Verificar credenciales]
    │
  [Generar OTP] → email asíncrono
    │
  [Validar código: existe + no expirado + no usado]
    │
  [Emitir token]

Resultado

AntesDespués
Sin defensa perimetralWAF absorbe el volumen bruto
Sin rate limitingBloqueo configurable desde BD sin redesplegar
Sin auditoríaRegistro de cada intento con IP y motivo
Contraseña = acceso totalSegundo factor obligatorio
Email síncronoEntrega asíncrona — no bloquea el endpoint

Lo que aprendí

Los parámetros en BD (umbrales, ventanas, duración del bloqueo) fueron lo que permitió ajustar la defensa en caliente durante el ataque sin tocar el código. El OTP asíncrono también es crítico: si el envío fuera síncrono, el propio ataque habría impedido que los emails llegaran.