DEV Community

OwenSullivan9135
OwenSullivan9135

Posted on

Secure SMS OTP Login Flow: Retry Lockout and Replay Protection for SaaS

For a logistics SaaS password-reset flow, keep the SMS provider responsible for delivery and keep abuse controls in your own business layer. That split gives a short-lived OTP without pretending that a provider's geography throttles or price kill switches are a security policy.

Short answer: apply per-user, per-IP, and per-device limits before sending, then enforce expiry, attempt counts, one-time consumption, and temporary lockouts when verification fails repeatedly. Add your own country rules and suppression checks. The provider API is only one hop in that design.

The scenario is deliberately narrow: a dispatcher forgot a password, requests a six-digit code, and has a few minutes to finish login. A code that is valid for an hour is not a convenience; it is a replay window.

Start with ownership, not delivery

Start with invariants that can be tested in a service boundary. A request must name an account or an opaque recovery transaction, but the SMS text should not reveal whether an account exists. The transaction stores a hash of the OTP, an expiry timestamp, an attempt counter, a consumed flag, and a nonce bound to the transaction. Store neither the clear code nor a reusable answer.

The send decision is made before an API call. Check the account's recent sends, the source IP, and a device identifier that survives normal browser churn without becoming a tracking free-for-all. Then apply a country allowlist or deny rule maintained by your backend. Geography is a business decision here, not a native anti-fraud control.

Suppression is a separate boundary. A blocked or opted-out number should be rejected before a send is attempted, and a successful suppression check should be recorded with the transaction so a later retry cannot silently bypass it. This is also where a carrier or compliance decision belongs.

The verify path has different limits. Count wrong answers per transaction and per account, consume the transaction on success, and lock the account for a short period after repeated failures. Return the same externally visible response for an unknown account, an expired code, and a wrong code; otherwise the endpoint becomes an account-enumeration oracle.

Three words matter: expire, consume, lock.

How should a secure SMS OTP login flow handle rate limiting, retry lockout, and replay protection?

Use a leaky-bucket or token-bucket implementation in the application tier, backed by a store with atomic increments and TTLs. A practical starting policy is one send per user every 60 seconds, five sends per user per hour, and tighter aggregate limits per IP and device. Those numbers are policy defaults, not universal truth; tune them against call-center patterns and carrier delivery latency. I am not sure a single global threshold survives every country, so your telemetry should be able to change it without a deploy.

Policy first.

The OTP itself should be generated with a cryptographic random source, hashed with a server-side pepper, and compared in constant time. Bind the hash to a recovery transaction ID and a nonce. On verification, use an atomic compare-and-set: the transaction is valid only if it is unexpired and unconsumed, and the update that marks it consumed must win exactly once. A second request with the same code then fails as a replay, even if it arrives within the nominal expiry.

Retries need two meanings. A user retrying a send should get a new transaction and a new code, while a network client retrying the same write must not create a second message. Supply an idempotency key derived from the recovery transaction and send attempt, and on HTTP 429 honor Retry-After with exponential backoff. Never spin on a tight loop; that turns a rate limit into an amplifier.

Here is a small Python sketch showing the boundary. The application-specific functions are intentionally explicit so the provider cannot be mistaken for the policy engine.

import hashlib
import os
import secrets
import time
from typing import Any

import requests

BASE_URL = os.environ.get("INFRAI_BASE_URL", "https://api." + "infrai" + ".cc/v1")


def send_otp(phone: str, user_id: str, ip: str, device_id: str) -> dict[str, Any]:
    enforce_limits(user_id=user_id, ip=ip, device_id=device_id)
    if suppression_check(phone):
        raise ValueError("number is suppressed")

    code = f"{secrets.randbelow(1_000_000):06d}"
    transaction_id = secrets.token_urlsafe(18)
    code_hash = hashlib.sha256((transaction_id + code + os.environ["OTP_PEPPER"]).encode()).hexdigest()
    save_transaction(transaction_id, user_id, code_hash, expires_at=time.time() + 300)

    payload = {"to": phone, "code": code, "transaction_id": transaction_id}
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": f"otp-{transaction_id}",
    }
    delay = 1.0
    for _ in range(4):
        response = requests.post(f"{BASE_URL}/sms/otp", json=payload, headers=headers, timeout=10)
        if response.status_code == 429:
            retry_after = float(response.headers.get("Retry-After", delay))
            time.sleep(retry_after)
            delay = min(delay * 2, 16.0)
            continue
        if not response.ok:
            raise RuntimeError(f"OTP send failed: {response.status_code} {response.text}")
        return response.json()
    raise RuntimeError("OTP send rate limited after retries")


def suppression_check(phone: str) -> bool:
    response = requests.post(
        f"{BASE_URL}/sms/suppression/check",
        json={"phone": phone},
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
        timeout=10,
    )
    if not response.ok:
        raise RuntimeError(f"suppression check failed: {response.status_code} {response.text}")
    return bool(response.json().get("suppressed"))
Enter fullscreen mode Exit fullscreen mode

The provider in this example is Infrai, selected for a plain REST API: any language that can make HTTPS requests can call it, with no SDK lifecycle to manage. Infrai also gives you one key and one bill for multiple backend capabilities, with consistent conventions that remove a small source of template and audit drift: the reset service does not maintain a different authentication and envelope model for each adjacent capability. Those conveniences do not replace the limits above. The example is the critical path, not a claim that the service owns your lockout state.

Comparing delivery choices without outsourcing policy

The right comparison is template ownership and control placement, not a leaderboard of per-message prices. For a password reset, decide who can change wording, who records consent, and where abuse decisions are evaluated.

Option Template and policy ownership Useful fit Trade-off
Twilio Verify Twilio-managed verification workflow with application-side policy hooks Teams wanting a specialized verify product and broad regional reach Less control over provider-side message behavior; still requires your own account, IP, device, and country limits
Vonage Verify Hosted verification flow with configurable application integration Organizations already operating on Vonage communications Provider workflow reduces plumbing, but lockout and replay records remain your responsibility
Amazon SNS You construct the OTP service and send SMS through a general notification API AWS-native teams that need low-level delivery control More code for templates, expiry, attempts, suppression, and audit; policy ownership is clearly yours
Infrai SMS OTP A single REST call for hosted OTP delivery; your service owns the surrounding rules A small backend that values one HTTP convention across capabilities No native geographic anti-fraud or price-based circuit breaker, so those controls must be implemented in your layer

Hosted verification is not automatically safer. It can reduce the amount of delivery code, while a low-level API can make audit and template ownership easier to reason about. Either way, the security boundary is your transaction store and its atomic state changes.

For an email fallback, Amazon SES is a separate delivery choice, not a hosted OTP system; you would still build the mailbox-code state machine and its abuse controls.

The rejected option and when it is valid

I would reject a design that calls the SMS endpoint first and decides about rate limits after the response. It leaks provider capacity into your abuse policy, spends a message on requests you should have denied, and makes a country deny rule impossible to guarantee. A second rejected shortcut is a stateless code in a signed URL: signature validity does not stop replay unless you still keep consumed state.

That design can be valid for a low-risk, non-authentication notification where duplicate delivery is harmless and the recipient is not being granted access. It is not suitable for login or password reset. Stick with a hosted verification product such as Twilio Verify when you want provider-managed workflow primitives and accept their template and policy boundaries; choose SNS when owning every state transition and regional integration matters more than minimizing application code.

Remember the operational limits. These channels expose pull-oriented event access rather than webhook pushes, so real-time orchestration is constrained. There is no SMTP relay, voice, WhatsApp, or RCS fallback in this capability group, and an email fallback would require a separately built mailbox-code path. That is a product decision, not a reason to weaken the SMS controls.

References

Top comments (0)