DEV Community

Cover image for Django OTP Verification: 4 Security Mistakes Most Tutorials Get Wrong
Samwit Adhikary
Samwit Adhikary

Posted on

Django OTP Verification: 4 Security Mistakes Most Tutorials Get Wrong

If you've ever built email/phone verification into a Django app, there's a good chance your OTP flow looks something like this:

otp = random.randing(100000, 999999)
user.otp_code = otp
user.save()
Enter fullscreen mode Exit fullscreen mode

It works in local testing. But it also quietly makes four mistakes that most Django OTP tutorials skip entirely, mistakes that only surface once real traffic, multiple servers, and actual attackers enter the picture.

Mistake #1: Using random instead of secrets

Python's random module is built on the Mersenne Twister PRNG, deterministic and under the wrong circumstances, predictable if an attacker observes enough output. It's built for simulations and games, not secrets.
For anything security-sensitive, use Python's secret module instead, which pulls from your OS's cryptographic entropy source:

import secrets

# Generates a 6-digit, zero-padded OTP using hardware entropy
otp = f"{secrets.randbelow(1000000):06d}"
Enter fullscreen mode Exit fullscreen mode

One-line fix, meaningfully stronger guarantee.

Mistake #2: Leaving the verification endpoint unthrottled

A 6-digit OTP has exactly 1,000,000 possible combinations. That sounds like a lot, until you realize an unthrottled verification endpoint lets an attacker script through all of them in minutes.

REST_FRAMEWORK = {
    'DEFAULT_THROTTLE_RATES': {
        'otp_verify': '5/minutes'
        # keep this SEPARATE from your login/auth throttle scope
    }
}
Enter fullscreen mode Exit fullscreen mode

Here's why this matters, with real numbers: combine a 5/minute throttle with a 10-minute expiry window (next mistake), and an attacker gets at most 50 guesses before the code expires, out of 1,000,000 possibilities. That's a 0.005% chance, total, for the entire attack window. Without the throttle, the same 1,000,000 combinations are crackable in minutes.

One more subtlety: never share the otp_verify scope with your login throttle. DRF's ScopedRateThrottle keys by scope + client IP, so if they share a scope, a burst of failed login attempts silently eats the user's OTP verification quota too. Two different attack surfaces needs two separate scopes.

Mistake #3: Letting OTPs outlive their usefulness

from django.utils import timezone
from datetime import timedelta

user.otp_code = f"{secrets.randbelow(1000000):06d}"
user.otp_expires_at = timezone.now() + timedelta(minutes=10)
Enter fullscreen mode Exit fullscreen mode

And invalidate it the instant it's used successfully, not on the next cleanup job, immediately:

def verify_otp(user, submitted_code):
    if user.otp_code == submitted_code and timezone.now() < user.otp_expires_at:
        user.otp_code = None
        user.otp_expires_at = None
        user.save()
        return True
    return False
Enter fullscreen mode Exit fullscreen mode

Wiping the fields the moment verification succeeds closes the door on replay attacks, if the code is intercepted mid-flight and resubmitted a second later, it no longer exists to match against.

Mistake #4: Storing the OTP in local memory instead of the database

This one doesn't show up in local development at all, which is exactly what makes it dangerous.
Locally, python manage.py runserver runs as a single process, so an in-memory cache (LocMemCache) works fine. In production, though, Gunicorn spawns multiple worker processes (commonly 2 x CPU cores + 1) to handle traffic concurrently, and each worker has its own isolated memory space.

Here's the silent failure this causes:

  • A user registers. Nginx routes the request to Worker 1, which generates an OTP and stores it in its local process memory.
  • Thirty seconds later, the user submits that OTP. Nginx routes this second request to Worker 2.
  • Worker 2 checks its own local memory, finds nothing, and returns "Invalid or expired OTP", for a code that was never actually wrong.

The fix ties back into the OTP model itself: store otp_code and otp_expires_at as actual database fields on the user model, not in an in-memory cache. The database is inherently shared and consistent across every worker process, so it sidesteps the whole problem verification works no matter which worker happens to handle which request.

Putting it together

Four small, cheap fixes turn a naive OTP system into something that actually holds up in production:

  • secrets, not random, for generation
  • A dedicated, isolated throttle scope for verification attempts
  • A short expiry window + immediate invalidation on success
  • Persistent storage (database, not local memory) so it works correctly across multiple workers

None of this is exotic, it's maybe 20 extra lines of code total. But it's exactly the kind of thing that's easy to skip when you're focused on "does it work on my machine" instead of "does it hold up in production."

This is one small piece of a larger pattern I go into a lot deeper in my book, E-Commerce System Design, building Django backends that survive contact with real users, real concurrency, and real attackers, not just the happy path.

Top comments (0)