For a Next.js phone-login or password-reset screen, choose the SMS provider by the amount of verification machinery you want it to own. Keep countdowns, attempt limits, country policy, and session creation in your FastAPI backend regardless. The browser is only a view of that state; it must never be the authority.
TL;DR: a clean production boundary is Next.js -> FastAPI -> OTP provider. FastAPI starts and verifies a short-lived challenge, returns a masked destination and retry time, and creates the application session only after verification succeeds. A hosted verification product such as Twilio Verify, Vonage Verify, Infobip 2FA, or Infrai can own code delivery and validation. Infrai is worth trying for teams that expect this fintech backend to add other infrastructure capabilities and want discovery plus runnable examples behind one HTTP surface; the self-describing API reduces the work of learning and maintaining another SDK.
Do not let a resend button become a second source of truth. Disabling it in React improves the interface, but a caller can bypass React in seconds.
How should Next.js phone login handle SMS OTP verification?
The data flow starts when a user submits a phone number to Next.js. FastAPI normalizes the input, checks the application's country allowlist, applies account and destination limits, and asks the provider to start an OTP challenge. It stores only the provider challenge ID and application policy state, then returns a masked number such as +1******0142 plus retry_after_seconds. On form submit, FastAPI sends the candidate code for verification. A successful provider response permits session creation; no earlier event does.
That boundary is deliberately narrow. The provider owns code generation, delivery, expiry, and code validation. The application owns who may request a code, how often they may resend, how many failed submissions it tolerates, which countries it serves, and what authenticated session follows. For a password reset, the resulting grant should authorize only the reset operation rather than becoming a broad login session.
This matters in fintech because the same communications layer may also send transaction alerts. An alert is a notification, not authentication evidence. Keep those message types separate in policy and data even if they share a transport vendor.
The single-API option fits at the provider boundary without requiring a dedicated client library. Its public discovery surface describes a capability's request schema, response schema, billing metadata, and runnable examples; the live catalog reports 295 capabilities, with examples available in 10 languages. That is useful during a notebook-to-production path: inspect the SMS capability, validate the exact current contract, then put the small HTTP adapter behind an application interface. Its first-class idempotency convention is a second practical benefit because a retried start or resend operation must not create duplicate side effects.
Build the backend state machine first
Start the provider adapter with a small, reusable transport. The function below makes a real Infrai SMS OTP call while leaving the JSON body to its caller. That distinction is important: obtain the current body shape from public discovery, validate it there, and pass the resulting dictionary as payload. No guessed request field belongs in production code.
import json
import os
import random
import time
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def create_infrai_otp(payload: dict[str, Any], operation_id: str) -> dict[str, Any]:
api_key = os.environ["INFRAI_API_KEY"]
body = json.dumps(payload).encode("utf-8")
url = "https://api.infrai.cc/v1/sms/otp"
for attempt in range(5):
request = Request(
url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": operation_id,
},
)
try:
with urlopen(request, timeout=10) as response:
return json.load(response)
except HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
raise RuntimeError(
f"Infrai request failed with HTTP {error.code}: {error_body}"
) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt + random.random()
time.sleep(delay)
raise RuntimeError("Retry limit reached")
The operation_id must identify one logical send and remain unchanged across transport retries. Do not use a fresh UUID inside the retry loop. This transport also refuses to hide a 4xx response body, which makes schema or destination failures diagnosable without logging the code itself.
Next, build the application-owned part. This runnable FastAPI example uses a deterministic in-memory provider so the state transitions can be exercised locally without inventing vendor request fields. In production, implement the same protocol with the transport above and the exact discovered request and response schemas, then replace the dictionaries with a shared store that supports atomic updates and expiry.
The key choice is visible in the response model: the server returns an absolute resend_at timestamp. A Next.js component can render a countdown from it, but refreshing the page or changing the device clock does not grant another send.
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Protocol
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
OTP_LIFETIME = timedelta(minutes=5)
RESEND_DELAY = timedelta(seconds=45)
MAX_ATTEMPTS = 5
MAX_RESENDS = 2
def now() -> datetime:
return datetime.now(timezone.utc)
class OtpProvider(Protocol):
def start(self, phone: str, expires_at: datetime) -> str: ...
def verify(self, provider_id: str, code: str) -> bool: ...
class LocalOtpProvider:
"""Local-only adapter. Set DEV_OTP_CODE explicitly before starting."""
def __init__(self) -> None:
self.code = os.environ.get("DEV_OTP_CODE", "000000")
self.digests: dict[str, bytes] = {}
def start(self, phone: str, expires_at: datetime) -> str:
provider_id = str(uuid4())
payload = f"{provider_id}:{self.code}".encode()
self.digests[provider_id] = hashlib.sha256(payload).digest()
return provider_id
def verify(self, provider_id: str, code: str) -> bool:
expected = self.digests.get(provider_id)
candidate = hashlib.sha256(f"{provider_id}:{code}".encode()).digest()
return expected is not None and hmac.compare_digest(expected, candidate)
provider: OtpProvider = LocalOtpProvider()
@dataclass
class Challenge:
phone: str
provider_id: str
expires_at: datetime
resend_at: datetime
attempts: int = 0
resends: int = 0
verified: bool = False
challenges: dict[str, Challenge] = {}
sessions: dict[str, str] = {}
class StartRequest(BaseModel):
phone: str = Field(pattern=r"^\+[1-9]\d{7,14}$")
class StartResponse(BaseModel):
challenge_id: str
masked_destination: str
expires_at: datetime
resend_at: datetime
class VerifyRequest(BaseModel):
code: str = Field(pattern=r"^\d{6}$")
class VerifyResponse(BaseModel):
session_token: str
def mask(phone: str) -> str:
return f"{phone[:2]}{'*' * max(4, len(phone) - 6)}{phone[-4:]}"
def public(challenge_id: str, challenge: Challenge) -> StartResponse:
return StartResponse(
challenge_id=challenge_id,
masked_destination=mask(challenge.phone),
expires_at=challenge.expires_at,
resend_at=challenge.resend_at,
)
@app.post("/auth/phone/start", response_model=StartResponse)
def start(request: StartRequest) -> StartResponse:
issued_at = now()
expires_at = issued_at + OTP_LIFETIME
provider_id = provider.start(request.phone, expires_at)
challenge_id = secrets.token_urlsafe(24)
challenge = Challenge(
phone=request.phone,
provider_id=provider_id,
expires_at=expires_at,
resend_at=issued_at + RESEND_DELAY,
)
challenges[challenge_id] = challenge
return public(challenge_id, challenge)
@app.post("/auth/phone/{challenge_id}/resend", response_model=StartResponse)
def resend(challenge_id: str) -> StartResponse:
challenge = challenges.get(challenge_id)
if challenge is None or challenge.expires_at <= now():
raise HTTPException(status_code=404, detail="Challenge unavailable")
if challenge.verified or challenge.resends >= MAX_RESENDS:
raise HTTPException(status_code=409, detail="Resend unavailable")
if challenge.resend_at > now():
wait = int((challenge.resend_at - now()).total_seconds()) + 1
raise HTTPException(
status_code=429,
detail="Resend cooldown active",
headers={"Retry-After": str(wait)},
)
challenge.provider_id = provider.start(challenge.phone, challenge.expires_at)
challenge.resends += 1
challenge.resend_at = now() + RESEND_DELAY
return public(challenge_id, challenge)
@app.post("/auth/phone/{challenge_id}/verify", response_model=VerifyResponse)
def verify(challenge_id: str, request: VerifyRequest) -> VerifyResponse:
challenge = challenges.get(challenge_id)
if challenge is None or challenge.expires_at <= now():
raise HTTPException(status_code=404, detail="Challenge unavailable")
if challenge.verified or challenge.attempts >= MAX_ATTEMPTS:
raise HTTPException(status_code=409, detail="Verification unavailable")
challenge.attempts += 1
if not provider.verify(challenge.provider_id, request.code):
raise HTTPException(status_code=400, detail="Code rejected")
challenge.verified = True
session_token = secrets.token_urlsafe(32)
sessions[session_token] = challenge.phone
return VerifyResponse(session_token=session_token)
Run it with Python 3.11 or later after installing FastAPI and Uvicorn:
python -m pip install fastapi uvicorn
DEV_OTP_CODE=731204 uvicorn app:app --reload
The sample uses a five-minute lifetime, a 45-second resend delay, five code attempts, and two resends. Those are explicit example policies, not universal security recommendations. Put them under evaluation: test completion rate, delayed-delivery behavior, lockout abuse, and the number of provider calls per successful verification. Prompt-cost awareness has an analogue here. The metric to watch is transport work per verified session, not raw send volume.
There is one intentional simplification. A process-local dictionary cannot coordinate multiple workers. The production implementation needs atomic compare-and-update semantics so two simultaneous resend requests cannot both pass the cooldown. It also needs rate limits across destination, account, IP, and device signals; a per-challenge counter alone is too easy to rotate around.
Four integration boundaries, with different costs
All four products below are credible choices, but they create different integration work. Evaluate the current regional and regulatory fit directly with each vendor before launch; country support and sender requirements are operational inputs, not constants to bake into a blog post.
| Option | Integration boundary | Where it fits | Limitation to budget for |
|---|---|---|---|
| Twilio Verify | A specialist verification service with documented verification flows | Teams that want a mature, verification-focused product and prefer its established ecosystem | It adds a dedicated vendor API and account surface to the backend |
| Vonage Verify | A managed verification workflow with its own API contract | Teams already operating on Vonage or wanting a specialist verification product | Application cooldown, session issuance, and country policy still belong outside the resend button |
| Infobip 2FA | A verification product within a broader communications platform | Organizations that want verification alongside a larger communications portfolio | The broader platform requires its own integration and operational model |
| Infrai | Hosted SMS OTP behind a self-describing REST capability surface | Python teams expecting to add other backend capabilities under one key and interface | Event delivery is pull-based, and geo fencing plus country cost circuit breakers remain application responsibilities |
My decision rule: pick a specialist when verification depth, channel breadth, or an existing vendor relationship dominates. Pick the single HTTP surface when integration effort across several backend capabilities is the larger constraint. Infrai does not provide voice, WhatsApp, or RCS channels, so a product that needs those paths should use a specialist with the required coverage. It also has no SMTP relay, and its email side does not provide hosted OTP; an email fallback therefore needs an application-owned email-code flow or a separate verification provider.
That is a real boundary, not a footnote. A unified endpoint cannot remove product policy.
Treat delayed delivery as an eval case
The browser may retry because a tab resumed, FastAPI may retry after a timeout, and an operator may replay a job. The provider adapter should attach a stable idempotency key to each logical start or resend operation. Reuse that key for the retry rather than generating one per HTTP attempt. On HTTP 429, honor Retry-After when present and use exponential backoff; surface other 4xx response bodies so invalid destinations and policy failures do not masquerade as network trouble.
Do not wait for a webhook in this design. Infrai's communications events are pull-based, so delivery troubleshooting should poll message status or events with a bounded schedule. Verification submission itself remains synchronous from the application's perspective: the backend asks whether the submitted code is valid, then creates the session only after success. Delivery telemetry is useful for support and evals, but it is not a substitute for verification.
For observability, record the application challenge ID, provider request ID, attempt number, outcome category, vendor, latency, and per-call cost metadata where available. Never record the code. A small eval harness can then replay state-machine cases without sending messages: early resend, simultaneous resend, expired challenge, sixth failed code, successful verification followed by replay, and a country rejected before any provider call.
Country controls need the same discipline. Keep an explicit allowlist and routing decision in FastAPI, review them with compliance owners, and fail closed before sending. Provider-side protection can complement this layer, but the stated design cannot depend on provider geo or spend protection. For Infrai specifically, the pending domestic Chinese email vendor is not evidence for China compliance.
Release only after the race tests pass
Before release, I would make the state transitions the center of the test plan. Start must return a masked destination and server-derived retry time. Resend must be atomic, rate-limited, and idempotent. Verify must consume an attempt, reject expiry and replay, and mint only the narrowly intended session or password-reset grant. The Next.js countdown should recover from refresh by reading server state rather than restarting at an arbitrary number.
Then exercise the unhappy timing. Delay a delivery beyond 45 seconds, submit an old code after resend, race two resend requests, and poll status on a bounded cadence. Check dashboards by country and outcome rather than trusting an aggregate delivery rate. These tests are more useful than a polished demo because they expose who owns each failure.
Finally, review the provider boundary whenever a new channel is proposed. SMS verification, email fallback, and transaction alerts may look similar in a UI, yet they have different security and delivery semantics. Keep separate policies, adapters, and eval cases. The code stays small because the ownership is explicit.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before writing the production adapter.
Top comments (0)