←

OTP rate limiting con triple defensa en Flask

Contexto

En un sistema de verificacion por WhatsApp OTP, un solo mecanismo de rate limiting no es suficiente. Un atacante puede abusar de la generacion de codigos, intentar fuerza bruta sobre el input, o simplemente espamear el boton de reenvio. Necesitaba tres capas independientes, cada una con su propio HTTP status code semantico.

Lo que aprendi

El patron de triple defensa protege tres vectores distintos, y cada capa retorna un status code HTTP que el frontend puede interpretar de forma diferenciada.

Capa 1: TTL del token (410 Gone)

El codigo OTP tiene una vida util configurable. Si expira, el recurso ya no existe -- semanticamente, 410 Gone.

CONFIRMATION_TOKEN_TTL = int(os.getenv('CONFIRMATION_TOKEN_TTL', 300))

# En la verificacion
elapsed = int(time.time()) - otp_generated_at
if elapsed > CONFIRMATION_TOKEN_TTL:
    return jsonify({
        "error": "El codigo ha expirado. Solicita uno nuevo.",
        "expired": True
    }), 410

Capa 2: Max intentos de input (429 Too Many Requests)

Cada OTP generado permite un numero limitado de intentos de verificacion. Esto previene fuerza bruta sobre el codigo de 6 digitos.

MAX_CONFIRMATION_ATTEMPTS = int(os.getenv('MAX_CONFIRMATION_ATTEMPTS', 3))

input_attempts = session.get('input_attempts', 0) + 1
session['input_attempts'] = input_attempts

if submitted_otp != stored_otp:
    remaining_attempts = max(0, MAX_CONFIRMATION_ATTEMPTS - input_attempts)
    if remaining_attempts <= 0:
        return jsonify({
            "error": "Has agotado los intentos de verificacion.",
            "max_attempts_reached": True,
            "redirect": url_for('inicio')
        }), 429

Capa 3: Cooldown entre reenvios (429 con metadata)

El reenvio de OTP tiene un cooldown temporal Y un limite maximo de generaciones. Ambos retornan 429 pero con metadata distinta para que el frontend diferencie.

RESEND_COOLDOWN = int(os.getenv('RESEND_COOLDOWN', 60))

# Limite de generaciones
if generation_attempts >= MAX_CONFIRMATION_ATTEMPTS:
    return jsonify({
        "error": "Has alcanzado el maximo de reenvios permitidos.",
        "max_reached": True
    }), 429

# Cooldown temporal
elapsed = int(time.time()) - last_generated
if elapsed < RESEND_COOLDOWN:
    remaining = RESEND_COOLDOWN - elapsed
    return jsonify({
        "error": f"Espera {remaining} segundos antes de solicitar otro codigo.",
        "cooldown_remaining": remaining
    }), 429

Por que tres capas y no una

Cada capa cubre un vector de ataque distinto: la expiracion previene replay attacks, el limite de intentos previene fuerza bruta, y el cooldown previene spam de generacion. Ademas, los tres valores son configurables via variables de entorno, lo que permite ajustarlos sin redesplegar.

Referencia