DEV Community

Raya
Raya

Posted on Originally published at myotp.app

Phone verification in Flask and Django with one API key

Most "phone verification in Python" tutorials are four years old and assume a Twilio account. Here's the version with one API key and two HTTP calls. I work at MyOTP.App, the API used below, and the full Flask and Django examples are in the brntech/myotp-agentkit repo.

The two calls

Send:

import os, re, requests

API = "https://api.myotp.app"
HEADERS = {"X-API-Key": os.environ["MYOTP_API_KEY"]}

def clean(phone: str) -> str:
    # digits only, country code first, no plus, no leading zero
    return re.sub(r"\D", "", phone).lstrip("0")

def generate_otp(phone: str, channel: str = "sms") -> dict:
    r = requests.post(f"{API}/generate_otp", headers=HEADERS,
                      json={"phone_number": phone, "channel": channel, "otp_length": 6})
    r.raise_for_status()
    return r.json()   # {"message_id": "...", "status": "accepted", "expires_at": "...", "cost": 1.0}
Enter fullscreen mode Exit fullscreen mode

Verify:

def verify_otp(phone: str, message_id: str, otp: str) -> dict:
    r = requests.post(f"{API}/verify_otp", headers=HEADERS,
                      json={"phone_number": phone, "message_id": message_id, "otp": otp})
    return r.json()   # {"status": "success"} or {"status": "failed", "reason": "invalid" | "expired" | "not found"}
Enter fullscreen mode Exit fullscreen mode

Everything else is your framework's job: where to keep message_id between the two requests, and what to do on success.

Flask

Keep message_id in the signed session cookie. It's an opaque UUID and the cookie is tamper-proof, so that's enough for a single server.

@app.post("/send")
def send():
    phone = clean(request.form["phone"])
    result = generate_otp(phone, request.form.get("channel", "sms"))
    session["message_id"], session["phone"] = result["message_id"], phone
    return redirect(url_for("verify"))

@app.post("/verify")
def verify():
    result = verify_otp(session["phone"], session["message_id"], request.form["code"])
    if result["status"] != "success":
        return render_template("verify.html", error=result["reason"]), 400
    session.pop("message_id")
    return redirect(url_for("done"))
Enter fullscreen mode Exit fullscreen mode

For more than one server, use flask-session or a database row instead of the cookie.

Django

Same shape, with the session backend you already have. Put the two functions in verification/services.py and the views become:

def send(request):
    phone = clean(request.POST["phone"])
    result = generate_otp(phone)
    request.session["otp"] = {"message_id": result["message_id"], "phone": phone}
    return redirect("verify")

def verify(request):
    pending = request.session.get("otp")
    result = verify_otp(pending["phone"], pending["message_id"], request.POST["code"])
    if result["status"] != "success":
        return render(request, "verify.html", {"error": result["reason"]}, status=400)
    del request.session["otp"]
    request.user.profile.phone_verified_at = timezone.now()
    request.user.profile.save()
    return redirect("done")
Enter fullscreen mode Exit fullscreen mode

The three mistakes

Calling the API from the browser. The key would ship in your JavaScript. Both examples above run server side.

Verifying by phone number instead of message_id. Two codes in flight to one number and you'll accept the wrong one.

Forgetting the IP allowlist. A key made from your laptop is allowlisted to your laptop. The first send from the server comes back 403 with "Access from this IP not allowed". Add the server IP in the dashboard. It's not the key.

Channels

channel takes sms, whatsapp or telegram. Nothing else in the code changes. If your users are somewhere SMS is slow or expensive, that one string is the difference.

Disclosure again: I work at MyOTP.App. 15 trial credits on signup, no card, and the examples are MIT.

Top comments (0)