¿Cómo se previenen realmente los ataques de fuerza bruta y credential stuffing contra un endpoint de login?
Los endpoints de login necesitan rate limiting por capas, bloqueos progresivos y CAPTCHA adaptativo, porque ningún control por sí solo detiene la fuerza bruta y el credential stuffing distribuido.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se previenen realmente los ataques de fuerza bruta y credential stuffing contra un endpoint de login?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dos ataques distintos, dos firmas distintas
"Alguien está intentando entrar como todos los usuarios" y "alguien está probando todas las contraseñas contra una sola cuenta" se ven parecidos si los miras de forma aislada, pero necesitan defensas distintas:
| Fuerza bruta | Credential stuffing | |
|---|---|---|
| Objetivo | Una cuenta (o un grupo pequeño) | Miles de cuentas |
| Contraseñas probadas | Muchos intentos contra pocos usuarios | Un par usuario+contraseña por intento, extraído de una filtración previa |
| Origen | A menudo una sola IP o una botnet pequeña | Un pool grande y distribuido de IPs (proxies residenciales, botnets) |
| Qué lo detiene | Rate limiting + bloqueo sobre la cuenta | Detectar credenciales filtradas reutilizadas + señales de dispositivo/comportamiento, porque los límites por IP apenas lo frenan |
Una defensa construida solo alrededor de "bloquear una IP después de N fallos" detiene la fuerza bruta ingenua y casi no hace nada contra el credential stuffing, donde cada una de diez mil IPs comprometidas hace exactamente una petición. Necesitás capas que operen sobre dimensiones distintas: IP, cuenta, y el par de credenciales en sí.
#Capa 1 — rate limiting, pero con las claves correctas
La primera capa es un contador de ventana fija (o deslizante), incrementado atómicamente en Redis (o un almacén compartido equivalente — esto no puede vivir en memoria por instancia detrás de un balanceador). Importan dos contadores independientes:
- Por IP de origen — detecta una sola máquina golpeando el endpoint, sin importar a qué cuenta apunte.
- Por cuenta/usuario — detecta credential stuffing y fuerza bruta lenta ("low-and-slow") distribuida entre muchas IPs de origen, porque el atacante igual tiene que golpear la misma cuenta repetidamente (o, en el caso del stuffing, golpea muchas cuentas una sola vez cada una, algo que el contador por cuenta no puede ver — por eso existe la capa 3).
#Capa 2 — bloqueo progresivo, no bloqueo permanente
Una política plana de "5 fallos, bloqueado para siempre hasta que soporte lo desbloquee" es en sí misma un vector de denegación de servicio: un atacante que conoce el nombre de usuario de su víctima puede bloquearla de su propia cuenta con solo fallar cinco logins a propósito, sin necesidad de adivinar la contraseña. La solución es un bloqueo progresivo y temporal — la espera crece con los fallos repetidos, pero siempre expira sola:
#Capa 3 — CAPTCHA como filtro adaptativo, no universal
Poner un CAPTCHA en cada login destruye la conversión para el 99.9% de los usuarios legítimos y apenas frena a atacantes que de todos modos pagan por servicios de resolución de CAPTCHA. El patrón útil es adaptativo: solo desafiar con un CAPTCHA cuando una señal cruza un umbral — un puñado de fallos recientes en esa cuenta, un intento de login desde un dispositivo nuevo o no reconocido, o un pico en la tasa de fallos desde un ASN. Por debajo del umbral, el flujo se mantiene sin fricción.
#Una implementación funcional
Acá va un guard en Go alrededor de un endpoint de login, usando Redis para contadores atómicos, combinando las tres capas. Está pensado para ejecutarse antes de verificar la contraseña (para el rate limit y el chequeo de bloqueo) y después de verificarla (para registrar el resultado):
package authimport ("context""errors""fmt""time""github.com/redis/go-redis/v9")var (ErrRateLimited = errors.New("too many attempts, try again later")ErrAccountLocked = errors.New("account temporarily locked"))const (ipWindow = 1 * time.MinuteipMaxAttempts = 20 // detiene una sola fuente golpeando el endpointaccountWindow = 15 * time.MinuteaccountMaxAttempts = 5lockoutBase = 30 * time.SecondcaptchaThreshold = 3 // fallos antes de forzar CAPTCHA en el siguiente intentomaxLockDuration = 30 * time.Minute)type LoginGuard struct {rdb *redis.Client}func NewLoginGuard(rdb *redis.Client) *LoginGuard {return &LoginGuard{rdb: rdb}}// CheckBeforeAttempt se ejecuta ANTES de verificar la contraseña. Nunca distingue// "cuenta desconocida" de "cuenta bloqueada" en su error — quien la llame debe// mostrar el mismo mensaje genérico en ambos casos, o el endpoint se convierte// en un oráculo de nombres de usuario.func (g *LoginGuard) CheckBeforeAttempt(ctx context.Context, ip, accountKey string) (requireCaptcha bool, err error) {ipKey := fmt.Sprintf("login:ip:%s", ip)acctKey := fmt.Sprintf("login:acct:%s", accountKey)lockKey := fmt.Sprintf("login:lock:%s", accountKey)locked, err := g.rdb.Exists(ctx, lockKey).Result()if err != nil {return false, err}if locked == 1 {return false, ErrAccountLocked}ipCount, err := g.rdb.Incr(ctx, ipKey).Result()if err != nil {return false, err}if ipCount == 1 {g.rdb.Expire(ctx, ipKey, ipWindow)}if ipCount > ipMaxAttempts {return false, ErrRateLimited}failCount, _ := g.rdb.Get(ctx, acctKey).Int64()return failCount >= captchaThreshold, nil}// RecordResult se ejecuta DESPUÉS de verificar la contraseña, haya tenido éxito o no.func (g *LoginGuard) RecordResult(ctx context.Context, accountKey string, success bool) error {acctKey := fmt.Sprintf("login:acct:%s", accountKey)lockKey := fmt.Sprintf("login:lock:%s", accountKey)if success {return g.rdb.Del(ctx, acctKey).Err()}failCount, err := g.rdb.Incr(ctx, acctKey).Result()if err != nil {return err}if failCount == 1 {g.rdb.Expire(ctx, acctKey, accountWindow)}if failCount >= accountMaxAttempts {overflow := failCount - accountMaxAttemptsshift := overflowif shift > 5 {shift = 5 // limita el exponente para que el bloqueo no crezca sin control}lockDuration := lockoutBase * time.Duration(int64(1)<<uint(shift))if lockDuration > maxLockDuration {lockDuration = maxLockDuration}return g.rdb.Set(ctx, lockKey, "1", lockDuration).Err()}return nil}
Las dos claves de Redis — login:ip:* y login:acct:* — expiran solas vía EXPIRE/SET ... EX, así que no hace falta un job de limpieza ni queda estado permanente que pueda dejar una cuenta bloqueada para siempre por error. INCR sobre una clave inexistente la crea atómicamente en 1, lo cual es lo que hace seguro el patrón "si count == 1, fijar expiración" bajo peticiones concurrentes — la pequeña ventana de carrera entre INCR y EXPIRE solo arriesga una ventana ligeramente más larga en la rara petición que pierde la carrera, nunca una infinita.
#El credential stuffing necesita una capa más: conocer tus contraseñas filtradas
El rate limiting no detiene el credential stuffing cuando cada cuenta se prueba una sola vez. La capa que realmente atrapa esto es verificar las contraseñas enviadas (o mejor, los pares email+contraseña enviados) contra bases de datos de credenciales filtradas conocidas, tanto al registrarse como al iniciar sesión — usando una consulta por rango con k-anonimato (enviás solo los primeros 5 caracteres de un hash SHA-1 y recibís todos los sufijos posibles) para nunca transmitir la contraseña real a un tercero. Si la contraseña de un usuario coincide con un corpus de filtraciones conocido, forzá un reseteo sin importar si este intento de login tuvo éxito — el atacante ya tiene una contraseña válida para esa cuenta.