← Back to blog

Send OTP Emails in Django (2026) with a Plain HTTP API

Build signup/login OTP in Django: secrets.randbelow, HMAC-hashed codes, cache or DB store, rate limits, urllib/httpx to POST /emails, and Idempotency-Key—Rovela delivers only.

Updated November 4, 2026
Send OTP Emails in Django (2026) with a Plain HTTP API

Why Django + OTP codes (not a magic-link SDK)

Django already gives signup and login a boring, reliable shape: function-based or class-based views, forms or DRF serializers, middleware and throttles for abuse, and a server-side store (database table or cache). You do not need a Rovela Django package, an “OTP product” endpoint, or an auth backend that pretends the email API is an identity provider.

What you need is the recipe in Email verification codes: your app mints a six-digit code with secrets.randbelow, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POST /emails with both html and text via urllib or httpx. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.

Rovela does not provide an OTP feature. Django owns cryptography, the model or cache entry, resend throttling, redeem logic, and the session / JWT. Rovela only delivers the mail.

This article is Django focused (views + a small service module, optional DRF): handlers for “send code” and “verify code,” a thin Rovela client, and an Idempotency-Key so a timeout retry does not enqueue two disagreeing codes. It is not a magic-link walkthrough—for those see FastAPI, Next.js, and NestJS. For the Express.js OTP sibling, see Send OTP emails in Express.js. For product choice between digits and URLs, see Email verification: code vs link. The language-agnostic OTP guide is Sending OTP codes by email; generic Python transport lives in Sending email in Python and How to send email with Python—this post does not rehash SMTP/smtplib or one-off “hello world” sends.

What you own vs what Rovela sends

Your Django app owns:

  • secrets.randbelow(1_000_000) → six digits, zero-padded (never random.randint)
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) via hmac + hashlib
  • Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
  • Upsert by normalized email so a new send invalidates the previous code (ORM row or cache key)
  • Constant-time compare on redeem (hmac.compare_digest); delete / invalidate on success
  • Identical public responses whether or not you want to reveal account existence (product choice)
  • Rate limits on send and verify views (Django cache throttle, DRF throttling, or nginx)

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in env—never commit it, never put it in settings that ship to the browser, never log it)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Settings and a tiny Rovela client

Keep secrets in environment variables (or a secrets manager), not in settings.py checked into git:

# config / env (illustrative)
# ROVELA_API_KEY=re_...          # sending_access
# CODE_PEPPER=long-random-secret
# OTP_FROM=Acme <security@mail.acme.com>
# OTP_TTL_MINUTES=10
# OTP_RESEND_COOLDOWN_SEC=60
# OTP_MAX_ATTEMPTS=5
# otp/rovela.py
from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from typing import Any


def send_email(
    *,
    to: str,
    subject: str,
    html: str,
    text: str,
    idempotency_key: str,
) -> dict[str, Any]:
    api_key = os.environ["ROVELA_API_KEY"]  # sending_access
    from_addr = os.environ.get(
        "OTP_FROM", "Acme <security@mail.acme.com>"
    )
    body = json.dumps(
        {
            "from": from_addr,
            "to": to,
            "subject": subject,
            "html": html,
            "text": text,
        }
    ).encode("utf-8")
    req = urllib.request.Request(
        "https://api.rovela.dev/emails",
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Rovela send failed: {e.code} {err}") from e

Prefer httpx in async Django / Channels workers the same way—same URL, headers, and JSON body. Wire ROVELA_API_KEY and CODE_PEPPER like any other server secret (Authentication, Quick Start).

Hash at rest: secrets + HMAC, not bare SHA-256

Six digits are only a million possibilities. A leaked table of bare SHA-256 hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server.

# otp/crypto.py
from __future__ import annotations

import hashlib
import hmac
import os
import secrets


def normalize_email(raw: str) -> str:
    return raw.strip().lower()


def mint_code() -> str:
    # CSPRNG — random.randint / random.random are not for secrets
    return f"{secrets.randbelow(1_000_000):06d}"


def hash_code(email: str, code: str) -> str:
    pepper = os.environ["CODE_PEPPER"].encode("utf-8")
    return hmac.new(
        pepper, f"{email}:{code}".encode("utf-8"), hashlib.sha256
    ).hexdigest()


def safe_equal_hex(a: str, b: str) -> bool:
    return hmac.compare_digest(a, b)

Suggested row shape (one active code per email), matching the verification-code example:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Django model sketch:

# otp/models.py
from django.db import models


class VerificationCode(models.Model):
    email = models.EmailField(primary_key=True)
    code_hash = models.CharField(max_length=64)
    expires_at = models.DateTimeField()
    attempts = models.PositiveSmallIntegerField(default=0)
    sent_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = "verification_codes"

Cache alternative: store {code_hash, attempts} under otp:{email} with TTL = expiry. Resend cooldown can be a second key otp:cooldown:{email}. Cache is fine for short-lived codes; use the DB when you need auditability or multi-worker consistency without a shared Redis.

Service: send + verify

# otp/service.py
from __future__ import annotations

import os
from datetime import timedelta

from django.db import transaction
from django.utils import timezone

from otp.crypto import hash_code, mint_code, normalize_email, safe_equal_hex
from otp.models import VerificationCode
from otp.rovela import send_email

SAFE_SEND_MSG = "If that address can receive mail, a code is on its way."


def send_otp(raw_email: str) -> dict:
    email = normalize_email(raw_email)
    ttl_min = int(os.environ.get("OTP_TTL_MINUTES", "10"))
    cooldown = int(os.environ.get("OTP_RESEND_COOLDOWN_SEC", "60"))

    now = timezone.now()
    existing = VerificationCode.objects.filter(email=email).first()
    if existing and (now - existing.sent_at).total_seconds() < cooldown:
        return {"ok": False, "error": "too_soon"}

    code = mint_code()
    code_hash = hash_code(email, code)
    expires_at = now + timedelta(minutes=ttl_min)

    VerificationCode.objects.update_or_create(
        email=email,
        defaults={
            "code_hash": code_hash,
            "expires_at": expires_at,
            "attempts": 0,
            "sent_at": now,
        },
    )

    # Stable per logical send: hash prefix. New code ⇒ new key.
    idempotency_key = f"otp-{code_hash[:32]}"

    send_email(
        to=email,
        subject=f"Your Acme code: {code}",
        html=(
            "<p>Your verification code is</p>"
            f'<p style="font-size:28px;font-weight:600;letter-spacing:4px">{code}</p>'
            f"<p>It expires in {ttl_min} minutes. "
            "If you did not ask for it, ignore this email.</p>"
        ),
        text=(
            f"Your verification code is {code}. "
            f"It expires in {ttl_min} minutes. "
            "If you did not ask for it, ignore this email."
        ),
        idempotency_key=idempotency_key,
    )
    return {"ok": True, "message": SAFE_SEND_MSG}


@transaction.atomic
def verify_otp(raw_email: str, raw_code: str) -> dict:
    email = normalize_email(raw_email)
    code = raw_code.strip()
    max_attempts = int(os.environ.get("OTP_MAX_ATTEMPTS", "5"))

    if not (len(code) == 6 and code.isdigit()):
        return {"ok": False, "error": "invalid"}

    row = (
        VerificationCode.objects.select_for_update()
        .filter(email=email)
        .first()
    )
    if row is None:
        return {"ok": False, "error": "invalid"}

    if row.expires_at < timezone.now():
        row.delete()
        return {"ok": False, "error": "expired"}

    if row.attempts >= max_attempts:
        return {"ok": False, "error": "locked"}

    match = safe_equal_hex(hash_code(email, code), row.code_hash)
    if not match:
        VerificationCode.objects.filter(email=email).update(
            attempts=row.attempts + 1
        )
        return {"ok": False, "error": "invalid"}

    row.delete()
    # issue session / JWT here
    return {"ok": True}

Putting the code in the subject helps lock-screen previews—fine for many products, skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails).

Django views + a rate-limit sketch

# otp/views.py
import json

from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from otp.service import send_otp, verify_otp

# Prefer CSRF-protected session forms in browser apps.
# csrf_exempt here only sketches a JSON API consumed by your SPA with other auth.


@csrf_exempt
@require_POST
def otp_send(request):
    try:
        payload = json.loads(request.body.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid_json"}, status=400)

    email = str(payload.get("email") or "")
    if "@" not in email:
        return JsonResponse({"error": "invalid_email"}, status=400)

    result = send_otp(email)
    if not result.get("ok") and result.get("error") == "too_soon":
        return JsonResponse({"error": "too_soon"}, status=429)
    return JsonResponse({"message": result.get("message", "ok")})


@csrf_exempt
@require_POST
def otp_verify(request):
    try:
        payload = json.loads(request.body.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid_json"}, status=400)

    email = str(payload.get("email") or "")
    code = str(payload.get("code") or "")
    result = verify_otp(email, code)
    if not result.get("ok"):
        err = result.get("error")
        status = 429 if err in {"locked", "too_soon"} else 400
        return JsonResponse({"error": err}, status=status)
    # Set session / return JWT
    return JsonResponse({"ok": True})
# otp/urls.py
from django.urls import path

from otp import views

urlpatterns = [
    path("auth/otp/send", views.otp_send, name="otp_send"),
    path("auth/otp/verify", views.otp_verify, name="otp_verify"),
]

Per-email cooldown inside the service and per-IP limits (Django cache, DRF AnonRateThrottle, Cloudflare, or your reverse proxy) solve different problems—use both. Hardening checklist: Email OTP security.

Idempotency-Key: retries without double codes

Workers, proxies, Celery retries, and AI agents retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.

  • Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
  • Derive the key from the code hash (otp- + first 32 hex chars) so one logical mint maps to one queued message.
  • A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-django-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

Deliverability notes for auth digits (not magic URLs)

OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.

If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.

OTP codes vs magic links in Django

Six-digit OTP (this post)Magic link
User actionType digitsClick URL
Secret entropyLow → HMAC + pepper requiredHigh URL token → SHA-256 of token is usually enough
Docs shapeVerification codesPassword reset / link pattern
Django surfacePOST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
Click trackingOptional concernPrefer off on the security sending domain

Both call the same POST /emails. Keep separate models and services—do not reuse an OTP row as a magic-link token. For a Python magic-link stack, see FastAPI magic link.

Checklist before you ship

  1. secrets.randbelow + HMAC pepper; never store raw codes.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limits.
  3. Upsert invalidates the previous code; verify deletes on success (select_for_update if using the DB).
  4. html and text; optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only on the server.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Verified from-domain; spam checklist if inbox placement slips.

Related reading

  1. Docs: Email verification codes · POST /emails · Quick Start · Authentication.
  2. Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
  3. UX choice: Email verification: code vs link.
  4. Deliverability: Why OTP and magic-link emails go to spam.
  5. Framework siblings: Express OTP · magic-link FastAPI / Next.js / NestJS.
  6. Python transport only: Sending email in Python · How to send email with Python.

Bottom line: put signup/login OTP in Django views that mint secrets codes, HMAC them at rest (model or cache), rate-limit send/verify, and call one urllib/httpx POST to Rovela with an Idempotency-Key—and never pretend the email API is your auth server.