DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Healthtech SMS OTP Login Rate Limiting (An Evidence-First Retry Policy)

Short answer: design SMS OTP login as a single-use challenge state machine, apply rate limiting before sending and before checking a code, and record enough immutable events to prove why a healthtech signup verification link was issued, accepted, locked, or rejected.

The simple version stores a code and an expiry time. It can tell you whether a user typed the expected digits. It cannot reliably answer the questions an incident reviewer or compliance team will ask later: Did one browser request 40 messages for different accounts? Did a correct code arrive after a lockout? Was the same successful challenge presented twice? Which policy version made the decision?

That evidence constraint changes the design. A delivery receipt is useful, but it is not proof of authentication. A successful OTP comparison is useful, but it is not enough unless redemption is atomic. For a US/EU SaaS handling healthtech signup, I would choose the flow that produces a coherent decision trail over the flow with the fewest database writes. The catch is extra state, more privacy review, and an eval harness that has to test time and concurrency rather than just happy-path responses.

What should a secure healthtech SMS OTP login flow record for replay protection?

Record decisions, not secrets. Each challenge needs an opaque identifier, a subject reference, purpose, creation and expiry times, attempt count, status, policy version, and a digest of the OTP rather than the OTP itself. Each decision event should capture a timestamp, challenge identifier, coarse request context, outcome, and reason code. Keep the verification link's token separate from the OTP, but bind both to the same signup intent so that neither can silently authorize a different action.

Do not put the code, full phone number, raw IP address, or verification token into logs. Those values increase the blast radius of an observability system without improving the core decision. A pseudonymous account key and a deliberately coarse network key can still support abuse analysis. Retention is where I am genuinely unsure without the organization's threat model, contracts, and legal review; the correct period varies, so make it an explicit policy input instead of copying a convenient log default.

The evidence model should distinguish at least these outcomes: issued, delivery requested, invalid code, expired, rate limited, locked, redeemed, and replay rejected. “Message sent” and “phone possession verified” must never share an outcome. This distinction sounds fussy until an audit query crosses service boundaries and a delivery callback is the only apparent success event. NIST SP 800-63B treats use of the public switched telephone network for out-of-band authentication as restricted and asks verifiers to consider risks such as device swap and number porting. That makes SMS a risk decision, not a universal proof of identity. If the assurance target or threat model does not tolerate that channel, use a phishing-resistant authenticator instead; no retry algorithm repairs a weak channel choice.

Keep it boring.

Model attempts as one state transition

The dangerous implementation reads status == "active", compares the code, then writes status = "redeemed". Two workers can pass the read before either write commits. Replay protection therefore belongs in the same atomic transition that consumes a correct challenge. A database transaction, conditional update, or compare-and-swap can enforce that only an active, unexpired, unlocked challenge becomes redeemed.

Here is a deliberately small Python model for the decision core. The numbers are policy hypotheses for an eval, not universal security constants. The production store must make verify() atomic for one challenge identifier.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
import hashlib
import hmac


class Status(str, Enum):
    ACTIVE = "active"
    LOCKED = "locked"
    REDEEMED = "redeemed"


@dataclass
class Challenge:
    challenge_id: str
    otp_digest: bytes
    expires_at: datetime
    attempts: int = 0
    status: Status = Status.ACTIVE


def digest_otp(secret: bytes, challenge_id: str, otp: str) -> bytes:
    message = f"{challenge_id}:{otp}".encode()
    return hmac.new(secret, message, hashlib.sha256).digest()


def verify(
    challenge: Challenge,
    submitted_otp: str,
    secret: bytes,
    now: datetime,
    max_attempts: int,
) -> str:
    if challenge.status is Status.REDEEMED:
        return "REPLAY_REJECTED"
    if challenge.status is Status.LOCKED:
        return "LOCKED"
    if now >= challenge.expires_at:
        return "EXPIRED"

    submitted_digest = digest_otp(secret, challenge.challenge_id, submitted_otp)
    if not hmac.compare_digest(submitted_digest, challenge.otp_digest):
        challenge.attempts += 1
        if challenge.attempts >= max_attempts:
            challenge.status = Status.LOCKED
            return "LOCKED"
        return "INVALID_OTP"

    challenge.status = Status.REDEEMED
    return "VERIFIED"


now = datetime.now(timezone.utc)
candidate = Challenge(
    challenge_id="signup_ch_7f3a",
    otp_digest=digest_otp(b"runtime-secret", "signup_ch_7f3a", "481205"),
    expires_at=now + timedelta(minutes=10),
)
Enter fullscreen mode Exit fullscreen mode

That snippet is intentionally not a complete SMS service. It omits storage, key management, link signing, and transport because those choices depend on the deployment. Its job is to expose the invariants for testing: expired never becomes verified, locked never returns to active, and redeemed never verifies again. In a real store, persist a monotonically increasing state version and append the decision event in the same transaction. Otherwise the login may succeed while the evidence write disappears, which defeats the article's primary decision axis.

Race it.

One more boundary matters: resend should not create several simultaneously valid codes. Either keep the same active challenge within the resend window or supersede the old challenge as the new one is created. Pick one rule, name it in the policy version, and test it. Don't let an SMS provider callback decide which challenge is current.

Rate limit issuance and verification separately

Sending and guessing are different abuse surfaces. The send path needs limits around the signup subject, destination, device or session, and coarse network context. The verify path needs limits around the challenge and broader context. A single counter keyed only by phone number misses distributed requests; a single counter keyed only by IP address punishes shared networks and is easy to rotate.

Use layered budgets with explicit scopes. For example, an initial experiment might allow a small burst per signup intent, a longer-window budget per destination, and a broader budget per network bucket. Those are starting values, not claims about safe limits. Run them against legitimate signup traces and scripted abuse cases, then examine false lockouts, messages issued per completed verification, and time to recovery. Prompt-cost awareness has a close analogue here: every extra send has a measurable cost, but minimizing sends must not become the security objective.

Retry and resend also need different language in the API. “Retry” means another comparison against an active challenge. “Resend” means another delivery action under an issuance policy. If a client collapses them into one button and one counter, the backend still has to preserve the distinction.

Avoid permanent account lockout. It gives an attacker a denial-of-service lever. A temporary challenge lock plus a controlled path to start a new signup attempt is easier to reason about, though the recovery route must face the same issuance budgets. For higher-risk accounts or suspicious context, step up to another authenticator or manual recovery instead of increasing the SMS attempt count.

Bind the verification link to the same signup intent

In this healthtech scenario, the verification link and SMS OTP are two proofs within one registration workflow. Give the signup intent its own random identifier and bind every challenge to its purpose, expected subject, and allowed next action. After redemption, mint a short-lived continuation capability for that exact intent rather than treating phone_verified = true as an unrestricted session property.

This is a sharp edge. A globally reusable “verified” flag can cross accounts, browsers, or workflows if identifiers are mixed up. Purpose binding makes a correct OTP for signup useless for password reset, and intent binding prevents one signup's link from completing another signup. The continuation should also be single-use when it changes account state.

For a notebook-to-production path, I would first model these objects as plain records and generate transition sequences. Then I would move the same invariants into storage-level tests with two workers racing the identical OTP. The interface can stay small; the test surface should not.

Evaluate policy before copying it

An eval suite for OTP login should generate more than valid and invalid codes. Include a correct code at the expiry boundary, two concurrent correct submissions, invalid attempts immediately before a correct one, resend followed by the old code, lockout followed by another verification request, and the same verification link used for a second signup intent. Assert both the user-visible decision and the event trail.

A compact scorecard keeps the team honest:

Measure Why it matters Failure signal
Replay acceptance Tests atomic redemption Any second success for one challenge
False lockout rate Protects legitimate signup Valid users blocked by normal retries
Sends per completed signup Exposes resend loops and abuse Rising sends without rising completions
Decision-event completeness Supports compliance evidence A state change without its reason event
Cross-intent acceptance Tests purpose binding A token completes the wrong signup

Review the scorecard by policy version and region, but don't assume geography alone explains a change. Carrier behavior, traffic mix, and client releases can move the same metric. Your mileage may vary, especially for shared networks and recycled phone numbers.

This design is not suitable when SMS cannot meet the required assurance level, when users may not control a stable phone number, or when the product cannot protect authentication metadata with appropriate access and retention controls. In those cases, choose an authenticator aligned with the threat model and keep the same evidence discipline: explicit purpose, bounded attempts, atomic consumption, and testable reason codes.

Before copying any threshold, measure concurrency safety, replay acceptance, false lockouts, sends per successful signup, recovery completion, and audit-event gaps. The state machine is the easy part. Proving that every edge preserves both security and evidence is the production work.

Measure first.

References

Top comments (0)