Capítulo 4 de 5 · 13 min de lectura
Expiración y reintentos
Ventanas de vigencia, límites de intentos y backoff de reenvío calibrados para usuarios reales.
2FA en producción · 0/5 completados
- Qué verifica un OTP
- Tu primera verificación
- Estados del ciclo de vida
- Expiración y reintentos
- Métricas que importan
En este capítulo
Dos perillas definen el balance entre seguridad y conversión: cuánto dura un código y cuántas veces se puede intentar o reenviar. Muy estrictas y castigas al usuario con mala cobertura en el metro; muy laxas y regalas tiempo a quien está adivinando.
Ventanas de vigencia
Para SMS, cinco minutos es un buen punto de partida: cubre la gran mayoría de las entregas tardías sin dejar el código expuesto demasiado tiempo. Para canales de chat, que entregan casi instantáneo, puedes bajar a tres. Para voz, sube a diez, porque el usuario debe escuchar y anotar.
Intentos y reenvíos
Con cinco intentos sobre seis dígitos, la probabilidad de acertar al azar es 5 en 1.000.000 por verificación. El riesgo real aparece cuando el atacante puede pedir verificaciones nuevas sin límite: por eso el contador de intentos debe acompañarse de un límite de emisiones por número y por ventana de tiempo.
Entel, Movistar, WOM y Claro cobran de forma distinta el tráfico transaccional a sus propias bases frente a numeración fuera de red, así que un atacante que automatiza solicitudes de código hacia un rango de números ajeno a Chile puede generarte un gasto notable en pesos chilenos en pocas horas si no limitas por IP, por dispositivo y por número de destino a la vez.
Un patrón simple y efectivo es exigir un pequeño desafío (un captcha invisible o una validación de dispositivo) a partir de la tercera solicitud de código en diez minutos desde el mismo origen, sin bloquear por completo al usuario legítimo que simplemente no vio llegar el primer SMS.
// Resend backoff: 30s, 60s, 120s, then block for 1h
const RESEND_DELAYS_S = [30, 60, 120];
export function nextResendAt(sentAt: number[], now: number): number | "blocked" {
const recent = sentAt.filter((t) => now - t < 60 * 60 * 1000);
if (recent.length > RESEND_DELAYS_S.length) return "blocked";
const last = recent.at(-1) ?? 0;
const delay = RESEND_DELAYS_S[recent.length - 1] ?? 0;
return last + delay * 1000;
}Mensajes de error honestos, pero no informativos
Al usuario dile «El código no es válido o ya venció» para cualquier fallo. Distinguir «código incorrecto» de «número no registrado» ayuda a un atacante a enumerar cuentas. La precisión va a tus logs y a tu panel de métricas, no a la pantalla.