DEV Community

Cover image for Rate limiting en Next.js: qué proteger antes de elegir una librería
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Rate limiting en Next.js: qué proteger antes de elegir una librería

Rate limiting en Next.js: qué proteger antes de elegir una librería

Hay un patrón que se repite en proyectos web: alguien lee sobre credential stuffing, abre la terminal y corre npm install @upstash/ratelimit. Quince minutos después, hay un middleware que limita a 10 requests por IP por minuto sobre todas las rutas. El problema no es la librería —es buena— el problema es que esa configuración protege una API de imágenes de perfil con el mismo rigor que un endpoint de login, y bloquea a un usuario legítimo detrás de un NAT corporativo antes de que llegue a autenticarse.

Mi tesis es simple: rate limiting no es una dependencia, es una política de abuso. Y una política sin definición del activo, del abuso esperado y del costo del falso positivo no es seguridad; es ruido con latencia.

Antes de instalar nada, hay tres preguntas que necesitás responder. Este post es sobre esas preguntas.


Rate limiting en aplicaciones web Next.js: el modelo mental que falta

Cuando pensamos en rate limiting, tendemos a pensar en "cuántos requests por segundo". Pero eso confunde el mecanismo con el objetivo. El objetivo real es hacer que ciertos patrones de abuso sean costosos para el atacante sin hacerlos costosos para el usuario legítimo.

OWASP lo plantea en su Authentication Cheat Sheet desde el ángulo de autenticación: cuenta progresiva de intentos fallidos, lockout temporal, notificación al usuario. Lo que no dice —y es igual de importante— es qué no bloquear. Esa parte la tenés que decidir vos.

El marco que me parece más honesto tiene cuatro columnas:

Pregunta Lo que buscás definir
¿Qué activo protegés? Endpoint específico, recurso, flujo
¿Qué patrón de abuso esperás? Credential stuffing, scraping, DDoS de capa 7, spam de formularios
¿Cuánto cuesta el falso positivo? Usuario bloqueado, conversión perdida, soporte incorrecto
¿Cómo lo vas a observar? Métricas, logs, alertas, diferenciación hit/block

Sin esas cuatro columnas llenas, cualquier configuración que elijas es una conjetura. Puede funcionar. También puede bloquear usuarios reales en producción sin que nadie se entere hasta que llega el reclamo.


Dónde se rompe la receta estándar

El middleware global de rate limiting en Next.js tiene un caso de uso legítimo: proteger rutas públicas de scraping masivo o de ataques de fuerza bruta sobre login. Pero viene con costos que los tutoriales suelen omitir.

El problema de la IP compartida. Si limitás por IP y el usuario está detrás de un proxy corporativo o un NAT universitario, decenas o cientos de usuarios distintos comparten la misma dirección. Un solo usuario activo puede consumir el budget del resto. No es un edge case: es el escenario normal de cualquier app B2B.

El problema del scope demasiado ancho. Un middleware en middleware.ts que intercepta /(.*) aplica el límite a /api/auth/login, /api/profile/avatar, /api/search y /sitemap.xml por igual. El costo de un falso positivo en login es muy distinto al de un falso positivo en imágenes. Mezclarlos te da protección aparente, no real.

El problema de la observabilidad ausente. ¿Cuántos requests bloqueaste hoy? ¿Cuántos eran legítimos? Sin esa distinción, no podés calibrar. No es que rate limiting sea malo —es que sin observabilidad, no sabés si está funcionando ni si está dañando.

Un patrón más defensivo en Next.js App Router se ve así:

// middleware.ts — rate limiting selectivo por ruta, no global
import { NextRequest, NextResponse } from 'next/server'

// Lista explícita de rutas que justifican protección
const RUTAS_PROTEGIDAS = ['/api/auth/login', '/api/auth/register', '/api/contact']

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname

  // Solo aplicar en rutas que definimos como activos críticos
  if (!RUTAS_PROTEGIDAS.some((ruta) => pathname.startsWith(ruta))) {
    return NextResponse.next()
  }

  // El mecanismo de conteo va aquí (Upstash, Redis, etc.)
  // Lo importante: este bloque tiene scope explícito, no implícito
  return NextResponse.next()
}

export const config = {
  matcher: ['/api/auth/:path*', '/api/contact/:path*'],
}
Enter fullscreen mode Exit fullscreen mode

La diferencia no está en la librería. Está en que el matcher es explícito. Si mañana agregás /api/upload, no hereda el límite por accidente: tenés que decidir conscientemente si lo protegés.


La matriz de decisión antes de elegir el mecanismo

Esta es la parte que más me interesa compartir porque es la que más se saltea. Antes de elegir entre Redis + Upstash, un middleware stateless con tokens, o el rate limiting del proveedor de nube, necesitás responder:

¿Qué activo protegés?

No todas las rutas tienen el mismo valor bajo abuso. Una forma de pensar en esto:

  • Alta sensibilidad: login, registro, reset de contraseña, endpoints de pago, envío de emails. El abuso acá tiene consecuencias directas: cuentas comprometidas, costo real en servicios de terceros, spam.
  • Sensibilidad media: búsqueda, listados públicos, APIs internas. El abuso acá es más de scraping o sobrecarga, no de account takeover.
  • Baja sensibilidad: assets estáticos, rutas de UI, sitemap. Protegerlos con rate limiting agrega latencia sin reducir riesgo real.

¿Qué patrón de abuso esperás?

Esto cambia el mecanismo, no solo el umbral:

  • Credential stuffing en login: querés límite por IP y por username, con backoff progresivo. OWASP recomienda específicamente no lockear cuentas de forma permanente para evitar que el atacante use eso como vector de DoS contra usuarios legítimos.
  • Scraping de listados: límite por IP con ventana deslizante. Acá sí importa el throughput, no los intentos fallidos.
  • Spam de formularios de contacto: límite por IP + honeypot + validación de origen. Rate limiting solo no alcanza si el formulario no tiene CSRF token.

¿Cuánto cuesta el falso positivo?

Esta es la pregunta que más incomoda porque obliga a poner número a algo que parece abstracto. Algunas preguntas para calibrar:

  • ¿Cuánto vale una sesión de usuario bloqueada por error? (costo de soporte, conversión perdida)
  • ¿Cuántos usuarios legítimos comparten IP en el segmento de mercado propio?
  • ¿Hay algún mecanismo de recuperación sin fricción si el rate limit se activa equivocado?

Si el costo del falso positivo es alto y el activo es crítico, el umbral tiene que ser conservador en el bloqueo pero generoso en el tiempo de recuperación.

¿Cómo lo vas a observar?

Un rate limiter sin métricas es un black box. Lo mínimo que necesitás:

// Ejemplo de logging mínimo al rechazar un request
// Adaptar al sistema de logs propio (pino, winston, stdout estructurado)
function logRateLimitEvent(request: NextRequest, resultado: 'bloqueado' | 'permitido') {
  const evento = {
    timestamp: new Date().toISOString(),
    ruta: request.nextUrl.pathname,
    ip: request.ip ?? 'desconocida',
    resultado,
    // Nunca logear headers de autenticación ni body acá
  }
  console.log(JSON.stringify(evento))
}
Enter fullscreen mode Exit fullscreen mode

Con esto, al menos podés hacer una query diaria: ¿cuántos bloqueados, en qué rutas, a qué hora? Sin eso, la política es opaca.


Errores comunes y sus costos reales

Usar el límite global como sustituto del análisis. "10 requests por minuto por IP en todo el dominio" suena razonable hasta que un bot usa 10.000 IPs rotativas y pasa igual, mientras que un usuario real con VPN corporativa se queda afuera.

Confiar en IP como identificador único. IPv4 con NAT y CDNs hacen que la IP sea un identificador ruidoso. Para rutas autenticadas, el identificador debería ser el user ID, no la IP. Para rutas públicas, la IP es lo que tenés, pero con los límites que implica.

No diferenciar entre 429 Too Many Requests con y sin Retry-After. Si bloqueás un request y no devolvés un header Retry-After, el cliente (y el usuario) no sabe cuándo reintentar. OWASP menciona el backoff como mecanismo explícito; el header es la forma en que el servidor lo comunica.

// Respuesta correcta con información de recuperación
return new NextResponse('Demasiados intentos. Esperá un momento.', {
  status: 429,
  headers: {
    'Retry-After': '60', // segundos hasta que puede reintentar
    'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 60),
  },
})
Enter fullscreen mode Exit fullscreen mode

Agregar rate limiting sin revisar si ya existe una capa upstream. Railway, Vercel y Cloudflare tienen controles de rate limiting propios. Agregar uno propio en middleware sin saber qué hace el upstream puede crear comportamientos inesperados —o simplemente duplicar trabajo sin reducir riesgo adicional.


Límites de esta guía: qué no podés concluir sin datos propios

Necesito ser directo sobre lo que esta guía no te da:

  • No hay umbrales universales. "10 requests por minuto" para login puede ser demasiado bajo para una app con usuarios móviles con reconexión frecuente, y demasiado alto para una app B2B donde un login legítimo rara vez se repite más de dos veces seguidas. El número correcto viene de observar el comportamiento real de los propios usuarios.

  • No hay evidencia de que rate limiting solo prevenga account takeover. OWASP lo trata como un control complementario, no como la defensa principal. Sin MFA, sin detección de credenciales comprometidas (haveibeenpwned.com tiene una API pública para esto), el rate limiting en login frena fuerza bruta simple pero no credential stuffing sofisticado con IPs rotativas.

  • No podés calibrar el falso positivo sin logs. Cualquier número que elijas hoy es una hipótesis. La calibración viene de observar cuántos requests legítimos se acercan al umbral en condiciones normales.

Esto conecta con algo que ya traté en el post sobre OAuth Scope Creep: los controles de seguridad tienen que diseñarse desde el riesgo específico, no desde la receta genérica. Y en OWASP LLM Top 10 en agentes llegué a una conclusión parecida: la guía te da el marco, pero la calibración la hacés con los propios datos.


FAQ: Rate limiting en Next.js

¿Upstash es la única opción para rate limiting en Next.js con App Router?
No. Upstash con Redis es popular porque funciona bien en entornos serverless (Vercel, Railway con Workers), pero podés implementar rate limiting con cualquier almacenamiento compartido: Redis propio, Memcached, o incluso una base de datos si el volumen lo permite. La elección depende de la latencia tolerada y del modelo de despliegue. Si el middleware corre en el edge, necesitás algo con latencia baja y compatible con el runtime de edge (sin Node.js nativo).

¿Tiene sentido aplicar rate limiting en rutas de assets estáticos?
En la mayoría de los casos, no. Los assets estáticos (/_next/static/, imágenes públicas) tienen un costo de abuso bajo y un costo de falso positivo alto (usuarios reales los consumen intensivamente en cargas de página). El rate limiting de CDN o del proveedor de hosting ya cubre este caso mejor que un middleware propio.

¿Cómo manejo usuarios detrás de NAT o VPN corporativa?
Para rutas autenticadas, usá el user ID como identificador del límite, no la IP. Para rutas públicas, podés combinar IP con fingerprinting de headers o con límites más generosos acompañados de detección de anomalías (muchos intentos fallidos de la misma IP). No hay solución perfecta acá: es un trade-off entre precisión del bloqueo y costo del falso positivo.

¿Qué devuelve el servidor cuando activo el rate limit? ¿Importa el mensaje?
Importa más de lo que parece. Un 429 sin Retry-After deja al cliente sin información para reintentar. Un mensaje demasiado específico ("bloqueado por exceso de intentos de login") puede dar información al atacante sobre el mecanismo. Lo razonable: 429 con Retry-After y un mensaje genérico orientado al usuario ("Demasiadas solicitudes, esperá un momento").

¿El rate limiting en middleware de Next.js protege también las Server Actions?
Depende de cómo lo configurés. Las Server Actions generan requests POST a la misma URL de la página, no a una ruta de API separada. Si el matcher del middleware no cubre esas rutas, las Server Actions no tienen el límite. Revisá el matcher explícitamente si querés proteger formularios que usan Server Actions.

¿Railway tiene rate limiting nativo que reemplace al del middleware?
Railway no tiene rate limiting de aplicación nativo (al momento de publicar esto). Sí tiene protección a nivel de infraestructura, pero no control granular por ruta o por usuario. Para lógica de abuso específica de la aplicación, necesitás implementarla vos. Si usás un proxy como Cloudflare delante de Railway, Cloudflare sí ofrece rate limiting por ruta que puede ser suficiente para casos simples.


Conclusión: la política antes que la librería

Treinta años de historia con tecnología me enseñaron que los errores más caros no son los que rompen el sistema —son los que dan sensación de control sin tenerlo. Un middleware de rate limiting instalado sin política definida entra en esa categoría: el 200 OK del deploy tapa el hecho de que no sabés qué protegés, contra qué, con qué umbral ni si estás dañando usuarios legítimos en silencio.

Mi recomendación práctica: antes de abrir cualquier librería, completá las cuatro columnas de la matriz. Activo, abuso esperado, costo del falso positivo, observabilidad. Si no podés completarlas, no tenés política —tenés configuración por imitación.

Después sí, elegí el mecanismo que encaje con el stack. Upstash para serverless, Redis propio si tenés el control, el rate limiting del proveedor de nube si el caso es simple. La tecnología es la parte fácil. Lo difícil es la decisión de diseño que va antes.

El próximo paso concreto: tomá una sola ruta crítica de la app —preferentemente login o registro— y respondé las cuatro preguntas para esa ruta sola. No todo el sistema. Una ruta, cuatro respuestas, un límite calibrado con logs. Eso es más útil que un middleware global configurado con números que alguien copió de un tutorial.


Fuentes


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)