Skip to Content
FeaturesBundled ExtensionsUser ManagementAccountsOTP & Brute-Force Protection

OTP Authentication & Brute-Force Protection

Email → 6-digit code → JWT tokens. No passwords.

Authentication Flow

Key design decisions

  • Anti-enumeration — all failure paths return identical HTTP 401 {"error": "Authentication failed"}. Wrong OTP, expired OTP, unknown email, and locked account are indistinguishable.
  • OTP reuse — if a valid unexpired OTP exists, a re-request returns the same code. Reduces SMTP load, avoids confusing users with multiple codes in inbox.
  • Active-only lookupOTPService always filters deleted_at__isnull=True. Soft-deleted accounts cannot authenticate.
  • Test account bypass — users with is_test_account=True accept any OTP code. Intended for App Store review / CI environments where real email delivery isn’t available.

Brute-Force Protection Layers

Four independent layers working in sequence:

All per-email state lives in Redis under SHA-256 hashed keys — no PII in cache:

key = f"otp:cooldown:{sha256(email.lower())[:16]}" # e.g. "otp:cooldown:b4c9a289323b21a0"

OTPRequestThrottle — prevent email bombing

from django_cfg.apps.system.accounts.services.brute_force_service import OTPRequestThrottle throttle = OTPRequestThrottle() # Before sending OTP allowed, reason, retry_after = throttle.check_email(email) # reason: "ok" | "cooldown" | "hourly_limit" | "daily_limit" # retry_after: seconds until retry allowed (0 if allowed) if allowed: throttle.record_sent(email) # sets cooldown + increments hourly/daily counters

Default limits (override via settings):

LimitDefaultSetting
Resend cooldown60 secondsOTP_RESEND_COOLDOWN_SECONDS
Hourly per email10 requestsOTP_HOURLY_LIMIT
Daily per email20 requestsOTP_DAILY_LIMIT

OTPVerifyThrottle — prevent brute-forcing

Brute-forcing a 6-digit code space (10^6 combinations) requires stopping after repeated failures.

from django_cfg.apps.system.accounts.services.brute_force_service import OTPVerifyThrottle throttle = OTPVerifyThrottle() # Check before verifying locked, retry_after = throttle.is_locked(email) if locked: return None # generic failure — do not reveal lockout # On wrong OTP: just_locked, remaining = throttle.record_failure(email) # On correct OTP: throttle.record_success(email) # clears failure counter + lockout

Default limits (override via settings):

LimitDefaultSetting
Max failed attempts5OTP_MAX_VERIFY_ATTEMPTS
Lockout duration15 minutesOTP_VERIFY_LOCKOUT_SECONDS

Soft Delete & Email Uniqueness

CustomUser uses a partial unique index on email instead of a global UNIQUE constraint:

-- Migration 0015 CREATE UNIQUE INDEX unique_active_email ON django_cfg_accounts_customuser (email) WHERE deleted_at IS NULL;

This allows multiple deleted accounts to share the same email (historical archive) while preventing duplicate active accounts.

user.soft_delete() # sets deleted_at, does NOT remove from DB user.is_deleted # True / False # Re-registering after deletion creates a fresh account user, created = CustomUser.objects.register_user( email="[email protected]", source_url="https://myapp.com", )

Cleanup Jobs

Two RQ cron jobs keep the database lean. Auto-registered when DjangoRQConfig.enabled = True:

JobCronPurpose
cleanup_expired_otps*/10 * * * *Delete expired/used OTPSecret rows
cleanup_jwt_blacklist0 3 * * *Flush expired JWT blacklist entries

Both are idempotent and safe to run manually:

from django_cfg.apps.system.accounts.services.cleanup_service import ( cleanup_expired_otps, cleanup_jwt_blacklist, ) cleanup_expired_otps() cleanup_jwt_blacklist()

Service Usage

from django_cfg.apps.system.accounts.services.otp_service import OTPService # Request OTP result = OTPService.request_otp( email="[email protected]", source_url="https://myapp.com", ) # result.success: bool # result.error_code: "invalid_email" | "cooldown" | "hourly_limit" | "daily_limit" # | "user_creation_failed" | "email_send_failed" | "internal_error" | "" # result.retry_after: int | None (seconds until retry allowed) # Verify OTP user = OTPService.verify_otp( email="[email protected]", otp_code="123456", source_url="https://myapp.com", ) # Returns user or None (always None on failure — no error details revealed to caller)

Source Files

FileRole
accounts/views/otp.pyOTP request + verify endpoints
accounts/services/otp_service.pyCore auth logic
accounts/services/brute_force_service.pyOTPRequestThrottle, OTPVerifyThrottle
accounts/services/cleanup_service.pyRQ cleanup jobs
accounts/models/user.pyCustomUser, soft-delete
accounts/models/auth.pyOTPSecret
accounts/migrations/0015_*.pyPartial unique email constraint

TAGS: otp, brute-force, OTPRequestThrottle, OTPVerifyThrottle, soft-delete, cleanup DEPENDS_ON: [index, jwt, two-factor]

Last updated on