Identificación de clientes
Un límite de cinco solicitudes por minuto no significa nada hasta que dices cinco por qué.
La limitación de tasa solo funciona si puedes identificar de forma consistente quién hace cada solicitud, y el identificador que elijas decide cuatro cosas a la vez:
- Equidad. ¿Estás limitando a la entidad correcta?
- Efectividad. ¿Alguien puede evadirlo?
- Experiencia. ¿Se está castigando a usuarios legítimos?
- Seguridad. ¿Se puede abusar del propio identificador?
La pregunta detrás de todo límite de tasa: ¿a quién o qué estás tratando de limitar realmente?
Dirección IP
La opción por defecto, la que obtienes sin pedirla. express-rate-limit usa la dirección del cliente a menos que le indiques lo contrario, que es justo lo que ha estado haciendo el limitador del capítulo anterior.
Sirve para endpoints públicos sin inicio de sesión, prevención básica de abuso, y frenar inundaciones simples.
Su atractivo es que siempre está disponible. No necesita autenticación, funciona con tráfico anónimo, es simple, y un usuario común no puede cambiarla a voluntad.
El problema es que las direcciones se comparten y se mueven. Cientos de empleados detrás de una misma conexión de oficina parecen un solo cliente, igual que todos los usuarios de una biblioteca y grandes cantidades de usuarios móviles detrás de un mismo operador.
Mientras tanto, un usuario cuya dirección cambia recibe un presupuesto nuevo cada vez que se mueve, y IPv6 entrega muchas direcciones dentro de una sola subred.
Para hacer explícita esta elección en vez de heredarla:
import { rateLimit, ipKeyGenerator } from 'express-rate-limit'
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
keyGenerator: (req, res) => ipKeyGenerator(req.ip),
})keyGenerator devuelve la cadena contra la que se cuenta una solicitud. Ese es el mecanismo que usa todo lo que sigue.
Todos los problemas de esta estrategia vienen de esa brecha.
req.ip es la dirección del proxy, así que todas las solicitudes parecen venir de un solo cliente y tu límite termina aplicándose al mundo entero a la vez. Express necesita app.set('trust proxy', ...) para leer en su lugar la dirección reenviada. Configúralo con el número de proxies que realmente tienes, porque confiar en el encabezado sin verificar le permite a un cliente falsificar cualquier dirección que quiera y evadir el límite por completo.
La respuesta es limitar por prefijo. express-rate-limit 8 expone ipv6Subnet para esto, con un valor por defecto de 56, donde los números más bajos agrupan de forma más agresiva.
El helper ipKeyGenerator existe por la misma razón: normaliza las direcciones para que los clientes IPv4 e IPv6 se identifiquen de forma consistente. Escribir req.ip directamente en la clave es justo lo que deja de funcionar silenciosamente el día que llega tráfico IPv6.
ID de usuario autenticado
Una vez que alguien inicia sesión, sabes exactamente quién es, ya sea a partir de una sesión, un claim de JWT o una consulta a la base de datos.
Sirve para endpoints autenticados, cuotas por usuario, y planes por niveles.
Cada usuario real tiene su propio presupuesto, así que nadie interfiere con nadie más. Los sigue a través de dispositivos y redes, y cambiar de dirección no cambia nada, porque la identidad no es la conexión.
Lo que no puede hacer es proteger nada antes del inicio de sesión. El registro, el restablecimiento de contraseña y los endpoints públicos ocurren todos antes de que exista un ID de usuario, y esos son justo los endpoints donde el abuso suele aparecer.
const limiter = rateLimit({
limit: 3,
windowMs: 10000,
keyGenerator: (req) => req.user.id,
})
app.get('/api/data', authenticate, limiter, handler)El orden importa. Express ejecuta el middleware de izquierda a derecha, así que la autenticación tiene que establecer req.user antes de que el limitador lo lea. Si pones el limitador primero, lee undefined para todos, y termina agrupando todas las solicitudes en el mismo bucket.
Eso es la equidad funcionando. Con direcciones, tres personas en la misma oficina estarían compartiendo un solo presupuesto de tres.
req.user.id antes de que se ejecute la autenticación, o lanza un error por undefined, o agrupa a todos bajo el mismo valor, lo que se traduce en un solo límite compartido para toda tu base de usuarios. Ninguno de los dos casos aparece en una prueba del camino feliz, porque con un solo usuario en la prueba no hay diferencia que notar.
Los límites también se convierten en parte del producto una vez que son por usuario. Los niveles gratuito y de pago con presupuestos distintos necesitan que el límite sea una función de la solicitud, no una constante, y la búsqueda del nivel del usuario termina en la ruta crítica de cada solicitud. Cachéala.
API key e ID de sesión
Dos identificadores más, cada uno adecuado para un tipo distinto de llamador.
| API key | ID de sesión | |
|---|---|---|
| Sirve para | Integraciones de terceros, APIs de negocio, planes por niveles | Aplicaciones web con sesiones, carritos, checkout y flujos de formularios |
| Fortalezas | Se vincula a una aplicación específica, admite niveles, se puede revocar si hay abuso, es simple de rastrear | Funciona tanto para usuarios con sesión como invitados, es más persistente que una dirección, sigue todo un recorrido |
| Debilidades | Las claves se comparten y se filtran, una sola clave puede servir a miles de usuarios finales, y requiere gestión de claves | Borrar las cookies la reinicia, aplica la fijación de sesión (session fixation), y no existe para APIs sin estado |
Una API key hace que los niveles surjan naturalmente, porque limit puede ser una función de la solicitud:
import { getUserTier } from './services/billing.js'
const limiter = rateLimit({
windowMs: 60000,
keyGenerator: (req) => req.headers['x-api-key'],
limit: (req) => (getUserTier(req.headers['x-api-key']) === 'pro' ? 1000 : 100),
})Un ID de sesión se lee de la misma manera, desde req.session.id.
Una API key identifica una aplicación. Un ID de usuario identifica a una persona. Una sesión identifica una visita. Una dirección identifica una conexión. Elige el que corresponda con lo que estás limitando.
Un socio con cincuenta mil usuarios finales tiene una sola clave, así que un usuario malintencionado consume el presupuesto de todos y termina limitando la integración entera.
Si eso importa, la clave necesita un presupuesto propio más otro por usuario final dentro de ella, lo que implica pasar un identificador de usuario final a través de la integración.
Hay dos cosas que vale la pena hacer sin importar en qué basas la clave. Nunca pongas un secreto sin procesar en la clave, ya que las claves del limitador terminan en logs y volcados del almacén, así que aplica un hash a la API key primero.
Y decide qué pasa cuando falta el identificador, porque si keyGenerator devuelve undefined, todas esas solicitudes se agrupan en un mismo bucket. Eso puede ser un comodín útil o un límite compartido por accidente, y debería ser el que tú elegiste.
Combinándolos
Los sistemas reales rara vez eligen uno solo. Lo habitual es armar una cadena con alternativas de respaldo, empezando por la más específica:
- ID de usuario autenticado, cuando alguien inició sesión.
- API key, para integraciones.
- ID de sesión, para invitados con una sesión.
- Dirección IP, como último recurso.
Cada escalón hacia abajo es menos preciso y más disponible, así que una solicitud siempre se cuenta contra el mejor identificador que realmente tiene.
Una persona con sesión iniciada es lo más claro que puedes identificar. Una dirección es lo más vago, y lo único que siempre está presente.
Suele ser mejor usar limitadores separados con sus propios presupuestos: uno estricto para el tráfico anónimo, uno generoso para los usuarios autenticados, en vez de un solo limitador que cambia de clave.
user:4821, nunca un simple 4821. Sin eso, un ID de usuario y un ID de sesión pueden coincidir en la misma cadena y dos clientes sin relación terminan compartiendo un presupuesto. Lo otro que esconde la cadena es que es una ruta de escalamiento: un atacante opera en el nivel que le resulte más barato de conseguir.
Así que la cadena es tan fuerte como su eslabón más débil, y los límites deberían volverse más estrictos a medida que los identificadores se vuelven más anónimos, en vez de mantenerse uniformes a lo largo de la lista.
Ponlo a prueba
Una API limita 100 solicitudes por minuto por dirección IP. Llegan tres quejas.
- Una empresa con 400 empleados dice que la aplicación deja de funcionar todas las tardes.
- Un usuario móvil dice que el límite nunca parece aplicarle.
- Alguien está scrapeando el catálogo público a un ritmo de aproximadamente 10.000 solicitudes por hora y nada lo detiene.
Compara tus respuestas
| # | Qué está pasando | Solución |
|---|---|---|
| 1 | 400 personas comparten una sola dirección de salida, así que 400 personas comparten 100 solicitudes por minuto | Basar la clave en el ID de usuario autenticado, dejando el límite por dirección solo para el tráfico anónimo |
| 2 | Su operador los mueve entre direcciones, así que cada cambio les da un presupuesto nuevo | La misma solución. Una identidad que sigue a la persona no se puede eludir reconectándose |
| 3 | El catálogo es público, así que no hay usuario contra quién basar la clave, y las direcciones le cuestan casi nada al scraper | Límites anónimos más estrictos, basados en un prefijo IPv6 en vez de una sola dirección, además de algo aguas arriba. Este caso se reduce en vez de resolverse |
El uno y el dos son el mismo defecto visto desde ambos lados: la dirección es demasiado amplia para la oficina y demasiado estrecha para el usuario móvil.
El tres es el límite honesto de toda la técnica. La limitación de tasa hace que el abuso tenga un costo, y contra un endpoint público ese costo nunca es más alto que el de tu identificador más barato.
Hacia dónde va esto ahora
El limitador cuenta correctamente y contra lo correcto. Lo que todavía le queda es el estallido en el borde de la ventana fija, que deja pasar el doble del límite alrededor de un reinicio.
Algoritmos de ventana deslizante resuelve eso, midiendo contra una ventana que se mueve con la solicitud en vez de un bloque fijo en el reloj.

