DEV Community

BriarVoss47291
BriarVoss47291

Posted on

How to Implement a SaaS SMS OTP API for US/EU Login (2026 Retry Rules)

Short answer: for a SaaS login, keep the OTP template and policy in your application, and treat the SMS API as a replaceable transport. This keeps US/EU signup verification predictable while your eval harness measures retries, delivery, and abuse in one place.

The useful unit is not a single API call. It is a short-lived challenge with a clear owner, an auditable decision, and a bounded number of attempts. I build RAG and agent features in Python, so I apply the same notebook-to-prod discipline here: define the event contract first, write an executable test, then connect a transport adapter.

Start With the Signup Data Flow

When a new workspace member enters a phone number, the application creates a challenge record. It stores a hash of the code, the purpose (signup), the region, an expiry timestamp, and counters for sends and checks. A worker asks an SMS API to deliver a message containing the code or a verification URL. The browser never trusts the delivery response as proof; it only trusts a successful code check against the server-side record.

Template ownership is the first decision. Application-owned templates keep product language, locale selection, and the link format in the same review process as the signup UI. Transport-owned templates can reduce setup work, but they move copy changes and approval evidence into a separate console. That is a real trade-off, not a footnote.

Here is a small, runnable core that leaves the actual SMS API behind one function. It is intentionally boring. Boring code is easy to evaluate.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets

@dataclass
class Challenge:
    phone: str
    code_digest: str
    expires_at: datetime
    send_count: int = 1
    check_count: int = 0
    consumed: bool = False

def digest(code: str, pepper: bytes) -> str:
    return hmac.new(pepper, code.encode(), hashlib.sha256).hexdigest()

def issue_challenge(phone: str, pepper: bytes, now: datetime) -> tuple[Challenge, str]:
    code = f"{secrets.randbelow(1_000_000):06d}"
    challenge = Challenge(
        phone=phone,
        code_digest=digest(code, pepper),
        expires_at=now + timedelta(minutes=5),
    )
    return challenge, code

def verify_challenge(challenge: Challenge, supplied: str, pepper: bytes, now: datetime) -> bool:
    if challenge.consumed or now >= challenge.expires_at or challenge.check_count >= 5:
        return False
    challenge.check_count += 1
    valid = hmac.compare_digest(challenge.code_digest, digest(supplied, pepper))
    if valid:
        challenge.consumed = True
    return valid

if __name__ == "__main__":
    current = datetime.now(timezone.utc)
    record, code = issue_challenge("+15551234567", b"test-pepper", current)
    print(verify_challenge(record, code, b"test-pepper", current))
Enter fullscreen mode Exit fullscreen mode

The example never logs the code. In production, persist the digest and counters atomically, and give the challenge an idempotency key derived from the signup attempt. A repeated browser click should not create five messages.

That last detail has teeth. A mobile browser can retry a request after losing connectivity even though the first request reached your service. Without an idempotency key, the user sees two valid-looking texts, enters the older one, and support gets a confusing “wrong code” ticket. The fix is to make the signup attempt the durable parent, record the first send decision, and return that decision for later identical requests. Keep the provider request id beside it. This is a small state machine, but it is the difference between a demo and a login flow you can explain during an incident review.

Ship it.

How Should a SaaS SMS OTP Login Handle Retry and Rate Limits?

Retries need two separate budgets. A transport retry covers a timeout before you know whether the provider accepted the message. A user retry covers a person asking for another code. Mixing them creates duplicate texts and makes abuse harder to spot.

For transport calls, retry only on an unknown outcome or a documented transient response, with exponential backoff and jitter. Cap the sequence. If the provider returns a permanent validation response, changing the phone number or payload is required; retrying the same request just burns quota. Record the provider request id when available so support can trace one logical send.

For the user, a practical starting policy is one send every 30 seconds, five sends per hour per phone number, and five verification attempts per challenge. Those are policy examples, not universal limits; your threat model and carrier mix should drive the final values. I am not sure a single global number survives every market, especially for shared office phones, so measure false lockouts before tightening it.

Rate-limit on several keys: normalized phone, account, IP prefix, and device/session. Keep the response deliberately vague: “If that number can receive messages, a code is on its way.” This prevents account enumeration. A token bucket in Redis or your edge gateway works, but the policy must still be represented in application tests so a migration does not change behavior silently.

Make the Verification Link Safe to Ship

A link can improve completion on mobile, but it carries more context than six digits. Generate a random, single-use token, bind it to the challenge and intended workspace, and expire it at the same time as the code. Do not put the phone number, email, or internal account id in the URL.

Use HTTPS, set a strict referrer policy, and consume the token in one transaction. If the link opens on a different device, ask for a secondary confirmation instead of assuming possession. The SMS channel is a possession signal, not a complete identity proof; NIST SP 800-63B treats it accordingly and discusses stronger authenticators for higher-risk actions.

Copy is part of the security boundary. Include the product name, a short expiry statement, and a support path. Avoid a link shortener that hides the destination. For US and EU traffic, select locale and sender policy from the account region, then record the template version in the challenge event. That version is what lets an eval compare a conversion change with a copy change.

Evaluate Providers Without Losing Template Control

Create a narrow adapter such as send_sms(destination, body, idempotency_key) -> DeliveryReceipt. The rest of the service should know about a receipt, not a vendor-specific status vocabulary. In an evaluation harness, replay the same fixtures through a fake adapter: accepted, timeout, throttled, invalid destination, and delayed delivery. Assert that each case produces one user-visible outcome and one audit event.

A provider console may offer approved templates, regional sender selection, delivery callbacks, and built-in fraud controls. Those features can be valuable when your team has limited operations capacity. The cost is ownership drift: a copy edit may need a second review, and a callback schema becomes part of your data model. A self-hosted gateway gives you more control but leaves carrier registration, deliverability work, and on-call burden with your team.

Decision Application-owned template Transport-owned template
Copy and locale review One product workflow Split across product and provider tooling
Provider replacement Adapter plus payload migration Template recreation and approval
Operations burden More registration work in-house More managed carrier operations

The decision rule I use is simple: choose application-owned templates when product experiments and auditability matter most; choose managed templates when regulatory registration and regional carrier operations are the bottleneck. Either way, keep the adapter contract and fixtures in your repository.

Operational Checks Before Launch

Start with a notebook that replays signup events for both regions. Turn its assertions into CI tests before wiring a live account. Check that a duplicate request reuses the idempotency key, that an expired link cannot be consumed, and that five wrong codes do not reveal whether the phone exists.

Watch send acceptance, delivery callback latency, verification success, resend rate, and lockout rate by country and template version. Alert on a sudden change in any one of those dimensions; a flat global success rate can hide a carrier-specific failure. Keep message bodies and phone numbers out of ordinary logs, and give support a redacted challenge id instead.

When a metric moves, follow the event chain in order: challenge created, send accepted, carrier callback received, code checked, and challenge consumed. A gap between creation and acceptance points at your queue or adapter. Acceptance with no callback points at carrier routing, registration, or callback handling. Callbacks with falling checks usually mean the message is late, the locale is wrong, or the user is seeing an older code. Compare template versions before changing retry limits; otherwise a copy regression can look like an infrastructure regression. In my eval notebooks I keep one fixture for each gap and replay it after every dependency upgrade. The test is cheap, and it catches accidental changes to status mapping, timeout handling, and redaction. Your mileage may vary by carrier, but the diagnostic order stays useful because it follows causality rather than a vendor dashboard's summary score.

The catch is that SMS is not suitable as the only factor for administrator actions, recovery of high-value accounts, or environments with strict phishing resistance requirements. Stick with a passkey or a hardware-backed authenticator for those paths, and reserve SMS OTP for the lower-risk signup step or as a recovery option with extra review.

References

Top comments (0)