OTP Phone Authentication for Indian Consumer Apps in Django

Phone-number OTP login for an Indian consumer app in Django: an SMS provider plus DRF simplejwt, and why OTP delivery gets its own Celery queue.

OTP Phone Authentication for Indian Consumer Apps in Django
Contents

The first real product decision I made on this veterinary clinic-management platform — one I built solo for the Indian market back in 2019 — had nothing to do with clinics. It was this: the login is a phone number and a six-digit code, not an email and a password.

That sounds obvious now. It wasn’t the default I reached for. Django hands you username + password out of the box, djangorestframework-simplejwt assumes the same, and every tutorial I’d read wired auth that way. But the users were pet owners in Indian towns. A lot of them don’t have an email they check. All of them have a phone number, and they already expect “enter your number, get an OTP” because that’s how every app they use — payments, food, ride-hailing — logs them in. Phone is the identity here. Building email auth and bolting phone on later would have been solving the wrong problem first.

So I want to walk through how that actually gets built: the two-step OTP flow, how you marry it to simplejwt when there’s no password to check, and the one delivery-latency decision that stopped OTPs from ever feeling slow.

Phone as the identity, not a field on the user

If phone is the login, model it that way from the start. A custom user model with phone as the unique identifier and no usable password:

class User(AbstractBaseUser):
    phone = models.CharField(max_length=15, unique=True, db_index=True)
    is_active = models.BooleanField(default=True)

    USERNAME_FIELD = "phone"
    objects = UserManager()

When a user first verifies a code, you create them with set_unusable_password() — there is no password, and there never will be. That one line saves you from a whole category of “forgot password” flows, credential-stuffing surface, and password-reset email plumbing you’d otherwise own. The unconventional-looking choice actually removes code.

Store the phone in one normalized form. Indian numbers show up as 9876543210, +919876543210, 0919876543210 — normalize to a single canonical shape on the way in so get_or_create doesn’t quietly mint duplicate accounts for the same human.

The two-step flow

OTP auth is two endpoints, not one. Request, then verify.

Request takes a phone number, generates a code, stashes it, and fires off the SMS:

class RequestOTPView(APIView):
    permission_classes = [AllowAny]
    throttle_classes = [RequestOTPThrottle]

    def post(self, request):
        phone = normalize_phone(request.data.get("phone", ""))
        if not phone:
            return Response({"detail": "phone required"}, status=400)

        code = f"{secrets.randbelow(10 ** 6):06d}"
        cache.set(f"otp:{phone}", make_password(code), timeout=OTP_TTL)

        send_otp_sms.apply_async(args=[phone, code], queue="send_text_messages")
        return Response({"detail": "code sent"}, status=200)

Two things I’d flag here that took me a beat to get right early on.

Use secrets, not random. This code is a credential, however short-lived. random is a predictable PRNG; secrets.randbelow is the CSPRNG you want for anything an attacker would love to guess.

And don’t store the plaintext code. I keep it in the cache (Redis, in production) hashed with Django’s make_password, keyed by phone with a short TTL. If someone somehow reads the cache, they get a hash, not a live code. The TTL doubles as the expiry — no cron to sweep stale codes, they just evaporate.

Verify checks the code and, if it’s good, mints tokens:

class VerifyOTPView(APIView):
    permission_classes = [AllowAny]
    throttle_classes = [VerifyOTPThrottle]

    def post(self, request):
        phone = normalize_phone(request.data.get("phone", ""))
        code = request.data.get("otp", "")

        hashed = cache.get(f"otp:{phone}")
        if not hashed or not check_password(code, hashed):
            return Response({"detail": "invalid or expired code"}, status=400)

        cache.delete(f"otp:{phone}")  # single-use, burn it on success

        user, _ = User.objects.get_or_create(phone=phone)
        refresh = RefreshToken.for_user(user)
        return Response({
            "access": str(refresh.access_token),
            "refresh": str(refresh),
        })

Marrying OTP to simplejwt

This is the part that trips people up, so here’s the whole trick in one line:

refresh = RefreshToken.for_user(user)

simplejwt’s advertised path is TokenObtainPairView — POST a username and password, get an access/refresh pair. That view is useless to you, because you have no password to obtain the pair with. But RefreshToken.for_user(user) is the escape hatch: it mints a valid token pair for any user you hand it, no credentials involved. You’ve already authenticated the human — the SMS code was the authentication. simplejwt just issues the session.

So the mental model is clean: OTP is your authentication mechanism, JWT is your session mechanism, and they meet at exactly that one call. From there everything downstream is ordinary simplejwt — Authorization: Bearer <access> on every request, refresh rotation, the works. The mobile and web clients never know or care that the login was phone-based; they just got a bearer token like any other API.

For a decoupled setup — a React admin dashboard plus native mobile clients all hitting one DRF API — stateless JWT is the right call over session cookies. Nobody’s sharing a cookie jar across an Android app and a separate web origin.

The decision that kept OTPs fast: their own queue

Here’s the one I’m quietly proud of, and it’s small.

Look back at the request view. The SMS doesn’t go out inline — it’s a Celery task, and it’s pinned to a specific queue:

send_otp_sms.apply_async(args=[phone, code], queue="send_text_messages")

You do not want to call the SMS gateway inside the request/response cycle. That HTTP call to the provider can take a few hundred milliseconds on a good day and stall on a bad one, and there’s no reason the user should watch a spinner while your server talks to a third party. Hand it to Celery, return immediately, let the worker do the slow part. Standard stuff.

The less standard part is which queue. The platform’s Celery ran five dedicated queues, one per workload — send_text_messages, reminders_cron, export, import, wallet — each in its own worker, with routing in a single dict:

# petapp_api/celery.py
task_routes = {
    "auth_profiles.tasks.send_otp_sms":   {"queue": "send_text_messages"},
    "export.tasks.export_bills_xlsx":     {"queue": "export"},
    "clinic.tasks.import_medicines":      {"queue": "import"},
    "wallet.tasks.apply_cashback":        {"queue": "wallet"},
    # reminders_cron carries the scheduled engagement jobs
}

The conventional move is one worker pool that you scale horizontally, and everything shares it. I split by queue instead, and the OTP path drove that choice. Think about what else lives on this backend: a receptionist kicks off a bulk Excel export of a clinic’s billing history. That task can run for a while, and nobody’s staring at it. If OTP delivery and that export share a worker pool, a fat export can sit at the head of the line and delay the OTP behind it.

And a late OTP isn’t a slow feature — it’s a broken login. The user typed their number, they’re holding their phone, and the code isn’t there. They don’t think “the export queue is backed up.” They think the app doesn’t work, and they leave. Every other slow task on this system degrades gracefully. OTP delivery is the one where latency reads as outage.

Giving send_text_messages its own worker means a stuck export physically cannot starve OTP SMS — different queue, different process, different worker. It cost me almost nothing to set up and it deleted an entire class of “why was my login slow” complaints before they could exist. That’s an infra decision, but it came straight out of knowing what the login feels like when it lags.

The task itself is boring, which is the point:

@shared_task(bind=True, max_retries=3, default_retry_delay=5)
def send_otp_sms(self, phone: str, code: str) -> None:
    body = f"{code} is your clinic verification code. Valid for 5 minutes."
    try:
        sms_client.send(
            to=phone,
            sender=settings.SMS_SENDER_ID,
            body=body,
            auth_key=settings.SMS_AUTH_KEY,
        )
    except SMSDeliveryError as exc:
        raise self.retry(exc=exc)

The provider was MSG91 — a common Indian SMS gateway — but the auth key and sender ID are env vars, never in code. If you’re building this today, budget for India’s DLT regime: transactional SMS goes out under a registered sender ID and a pre-approved message template, so your OTP copy isn’t something you freely reword at runtime. Wire the sender ID and template through config and you won’t fight it later.

Hardening it without over-building

A few things that matter more than they look:

Throttle the request endpoint hard. In India, SMS costs real money per send, so abuse isn’t just a spam problem — it’s a line item on your bill. Rate-limit by phone and by IP so nobody can enumerate numbers or run up your invoice. DRF’s throttle classes handle this cleanly.

Cap verify attempts. A six-digit code is a million possibilities; give an attacker unlimited guesses inside a five-minute window and that stops being safe. Count failures per phone and lock the code after a handful.

Don’t leak who’s registered. Request-OTP returns the same “code sent” whether or not that number has an account. You find out if they’re new only after a valid code, at get_or_create. Otherwise the login endpoint becomes an “is this person an existing user” oracle.

Single-use codes. Delete the cache key the instant a code verifies. A code that works twice is a replay waiting to happen.

What it bought

None of this is exotic. It’s a custom user model, two views, one for_user call, and a Celery route. But it fit the actual users instead of the framework’s defaults, and it kept the one latency-sensitive path insulated from everything slow around it.

The proof is boring in the best way. That auth flow shipped in 2019 and has been logging real clinics and pet owners in ever since — through the whole of 2024, when I didn’t touch the codebase at all. Phone-first was right for the market, OTP-plus-JWT was right for the clients, and giving OTP its own lane was right for the one thing a user will never forgive you for making slow: getting into the app.