Skip to content

Abuse Protection

Every endpoint that can send an email or SMS — or accept a login attempt — sits behind a layered set of abuse controls defined in craft_easy.core.auth.abuse and craft_easy.core.auth.captcha. These protections are on by default and tuned for the typical SaaS workload; all thresholds are tweakable from settings.

The layers stack, from cheapest to most expensive:

  1. Disposable email blocking — reject known throwaway domains before touching the DB.
  2. Per-recipient rate limiting — stop a single email/phone from being spammed.
  3. Global daily caps — hard ceiling on total SMS/email volume per UTC day.
  4. IP-based auth rate limiting — exponential backoff on repeated login attempts from a single IP.
  5. CAPTCHA (Cloudflare Turnstile) — optional challenge after repeated failures from an IP.

1. Disposable email blocking

craft_easy.core.auth.abuse.DISPOSABLE_EMAIL_DOMAINS ships with 60+ known throwaway domains (mailinator.com, tempmail.com, guerrillamail.*, 10minutemail.com, and so on). At every OTP login or signup, the email is checked with check_disposable_email:

from craft_easy.core.auth.abuse import check_disposable_email, DisposableEmailError

try:
    check_disposable_email("user@mailinator.com", settings)
except DisposableEmailError:
    raise HTTPException(400, "Disposable email addresses are not allowed")

Disable by setting BLOCK_DISPOSABLE_EMAILS=False. Add your own domains by extending the set at startup:

from craft_easy.core.auth.abuse import DISPOSABLE_EMAIL_DOMAINS
DISPOSABLE_EMAIL_DOMAINS.update({"yourcompany-bad-list.com"})

2. Per-recipient OTP rate limiting

A single email or phone number can only receive so many codes. This protects against an attacker burning through someone's inbox and against honest mistakes (user clicks "resend" 20 times).

from craft_easy.core.auth.abuse import check_recipient_rate_limit, RecipientRateLimitError

try:
    check_recipient_rate_limit("user@example.com", settings)
except RecipientRateLimitError as e:
    raise HTTPException(429, str(e))

The targets are hashed with an HMAC key before being used as counter keys — logs and in-memory state never see the raw email or phone.

Setting Default Meaning
OTP_RECIPIENT_MAX_PER_HOUR 5 Codes per recipient per rolling hour.
OTP_RECIPIENT_MAX_PER_DAY 10 Codes per recipient per rolling 24h window.

The default backend is in-memory. For a multi-process or multi-replica deployment, replace it with Redis by swapping out the _recipient_hourly / _recipient_daily dicts in your own fork of abuse.py, or front the API with a shared rate-limit proxy. The abstraction boundary is deliberately small.

3. Global daily caps

Every SMS costs real money, and a badly configured client can empty your budget in minutes. check_global_sms_cap and check_global_email_cap enforce a hard per-day ceiling:

from craft_easy.core.auth.abuse import (
    check_global_sms_cap,
    check_global_email_cap,
    GlobalCapExceededError,
)

check_global_sms_cap(settings)    # raises if day total ≥ MAX_DAILY_SMS
check_global_email_cap(settings)  # raises if day total ≥ MAX_DAILY_EMAIL

When usage crosses DAILY_CAP_WARNING_THRESHOLD (default 80 %), a warning is logged — wire it to your alerting so ops gets a heads-up before the wall is hit.

Setting Default Purpose
MAX_DAILY_SMS 10_000 Hard cap on SMS sends per UTC day.
MAX_DAILY_EMAIL 50_000 Hard cap on email sends per UTC day.
DAILY_CAP_WARNING_THRESHOLD 0.8 Log-warning level (0.0 – 1.0).

Counters reset at UTC midnight (_maybe_reset_daily_counters).

4. IP-based auth rate limiting

Every login attempt — OTP, OAuth2 callback, API-key — passes through check_auth_ip_rate_limit(ip, settings). The hourly limit is enforced with progressive backoff: the more you exceed it, the longer the wait:

retry_after = min(60 * 2 ** overshoot, 3600)
Setting Default Meaning
AUTH_IP_MAX_PER_HOUR 10 Login attempts per IP per rolling hour.
AUTH_IP_MAX_PER_DAY 30 Login attempts per IP per rolling 24h window.

A blocked caller receives:

HTTP/1.1 429 Too Many Requests
Retry-After: 240

{"detail": "Too many login attempts. Please try again later."}

AuthIPRateLimitError.retry_after carries the exact backoff in seconds; the route handler translates it into the Retry-After header.

5. CAPTCHA — Cloudflare Turnstile

The CAPTCHA layer is optional and off by default. When enabled, Turnstile kicks in automatically once an IP has failed more than CAPTCHA_FAILED_ATTEMPTS_THRESHOLD times in the last hour:

from craft_easy.core.auth.captcha import CaptchaService, CaptchaVerificationError

captcha = CaptchaService(settings)

# In the route handler:
if captcha.is_required_for_ip(get_failed_otp_attempts_for_ip(ip)):
    await captcha.verify(body.captcha_token, remote_ip=ip)

verify() posts the token to CAPTCHA_VERIFY_URL and raises CaptchaVerificationError if Turnstile says the token is missing, invalid, or the service is unreachable.

Setting Default Purpose
CAPTCHA_ENABLED False Master switch.
CAPTCHA_SECRET_KEY None Turnstile secret (kept in a secret store).
CAPTCHA_SITE_KEY None Turnstile site key (safe to expose to the browser).
CAPTCHA_VERIFY_URL https://challenges.cloudflare.com/turnstile/v0/siteverify Override for testing.
CAPTCHA_FAILED_ATTEMPTS_THRESHOLD 3 Failed attempts before CAPTCHA is required.

Client-side, embed the Turnstile widget with your site key, collect the token, and send it alongside the login request body.

Putting it together

The /auth/login/email route applies every layer in order:

check_auth_ip_rate_limit(ip, settings)                 # 4
check_disposable_email(body.email, settings)           # 1
check_recipient_rate_limit(body.email, settings)       # 2
check_global_email_cap(settings)                       # 3
if captcha.is_required_for_ip(get_failed_otp_attempts_for_ip(ip)):
    await captcha.verify(body.captcha_token, ip)       # 5
# ... only now do we actually generate and send the code

Because the checks are cheap and ordered from in-process to remote, a malicious caller is rejected as early as possible.

Tuning guide

  • Consumer app with heavy signup traffic. Raise OTP_RECIPIENT_MAX_PER_HOUR to 10, enable CAPTCHA, leave the global caps where they are.
  • Internal B2B app. Drop AUTH_IP_MAX_PER_HOUR to 5, drop MAX_DAILY_SMS to 500, keep disposable-email blocking on.
  • High-fraud environment. Enable CAPTCHA unconditionally from the client, set CAPTCHA_FAILED_ATTEMPTS_THRESHOLD=0, add your own industry-specific disposable domains.

All thresholds are per-process in-memory counters. Production deployments with multiple replicas should either stick to the global caps (which are the most load-bearing) or front the API with a shared rate-limit layer — the in-process counters will still catch anything that slips through.