Short answer: treat an SMS OTP flow as a small, persisted state machine, and make recipient suppression a separate decision from authentication. A resend cooldown, a bounded attempt counter, and abuse limits are more useful than a clever message template. For a media service, this keeps a typo in a phone number from becoming both a delivery problem and a login attack.
Experiment note: the failure was in the boundary
The first design I reach for is a single Express route that generates a code, sends it, and increments a counter. It is easy to demo. It is also difficult to reason about when a user taps “resend” three times, a carrier reports a delayed message, or an old code arrives after a newer one. A common media-product incident starts with a mobile client retrying after a timeout: the API accepted the first request, but the response was lost. When the counter increment and queue publish are separate, that retry creates another challenge. The safer design is to issue a durable challenge first, publish an idempotent send job second, and make every later decision derive from stored timestamps. The extra write is cheap compared with explaining six codes to a locked-out subscriber.
Ship the boring version first.
The better boundary is boring: the Node.js handler validates input and calls a store; a worker sends the SMS; a suppression service records invalid destinations. The login record owns state such as challenge_id, code_hash, expires_at, resend_at, and attempts. The suppression record owns a normalized phone number, a reason, and an expiry. They can share a database, but they should not share meaning.
That split matters for media teams. A bounced newsletter recipient may be suppressed for campaigns while still being allowed to complete a login after a verified number change. Conversely, a number that repeatedly requests codes from many IP addresses needs an abuse response even if delivery succeeds.
Measure before copying the choice: completion rate by carrier and country, resend rate per challenge, invalid-recipient rate, median time to first delivery, and the percentage of challenges rejected by each limit. I’m not sure your carrier mix will resemble mine; your mileage may vary.
What should passwordless phone login record for SMS OTP cooldowns and max attempts?
Use server time and one challenge identifier. On issue, generate a six-digit value with a cryptographic random source, store only a hash, and set a short expiry such as five minutes. On verify, compare against the hash, reject expired or consumed challenges, and increment attempts atomically. A successful comparison consumes the challenge; it cannot be replayed.
Resend should create a new code but keep a link to the same logical login attempt. A 30-second cooldown is a reasonable starting point, while a per-challenge cap (for example, five sends) prevents an accidental retry loop. Keep a wider per-number and per-IP budget as well. Return the same public response for “unknown number” and “known number”; that avoids turning the endpoint into a directory.
Here is the policy in a framework-neutral Python module. The Express controller can call the same rules from a service layer, or the rules can be ported directly to JavaScript without changing their inputs.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
@dataclass
class Challenge:
code_hash: bytes
expires_at: datetime
resend_at: datetime
attempts: int = 0
sends: int = 1
consumed: bool = False
def issue(now: datetime) -> tuple[str, Challenge]:
code = f"{secrets.randbelow(1_000_000):06d}"
digest = hashlib.sha256(code.encode("ascii")).digest()
return code, Challenge(
code_hash=digest,
expires_at=now + timedelta(minutes=5),
resend_at=now + timedelta(seconds=30),
)
def verify(challenge: Challenge, candidate: str, now: datetime) -> bool:
if challenge.consumed or now >= challenge.expires_at:
return False
if challenge.attempts >= 5:
return False
challenge.attempts += 1
expected = hashlib.sha256(candidate.encode("ascii")).digest()
if hmac.compare_digest(challenge.code_hash, expected):
challenge.consumed = True
return True
return False
def can_resend(challenge: Challenge, now: datetime) -> bool:
return now >= challenge.resend_at and challenge.sends < 5
The counters in this snippet are examples of a policy, not universal constants. Persist the state with a conditional update so two concurrent requests cannot both pass the check. In Redis that might be a Lua script; in Postgres it can be an UPDATE ... WHERE attempts < 5 followed by a row-count check. The important property is atomicity, not the storage brand.
Suppression is a data lifecycle, not an SMS error handler
Normalize phone numbers to E.164 before hashing or looking them up. Store provider-independent reasons such as hard_bounce, invalid_format, or user_opt_out, plus the source event and a review timestamp. A hard bounce should block campaign sends until an explicit correction; a transient delivery failure should usually go through retry policy instead.
Keep it explicit.
For a media platform, join suppression at send time, not only when importing a list. Lists get copied. A final check immediately before enqueueing a campaign message closes the gap between an unsubscribe event and a scheduled send. Keep authentication traffic on its own queue and metrics, so a noisy campaign cannot starve login codes.
SMS length is another quiet failure mode. GSM-7 and UCS-2 use different character budgets, and segmentation can turn one apparent message into several billable segments. Keep the OTP text ASCII, short, and free of smart punctuation. Twilio’s character-limit reference explains why a single non-GSM character changes the calculation.
Anti-abuse controls that survive retries
Rate-limit dimensions independently: account or phone number, source IP, device fingerprint, and ASN where available. Add exponential backoff after repeated failures, but do not reveal which dimension tripped. Log a reason code internally and return a generic “try again later” response externally.
Do not use the SMS provider as your lockout database. Provider callbacks are asynchronous and can be duplicated; process them idempotently with an event identifier. A callback that marks a number suppressed should be safe to replay, and a late callback must not unsuppress a number that a later event blocked.
The catch is usability. Aggressive limits strand travelers with roaming numbers and households behind one NAT. Make support recovery explicit, and offer an alternate verified factor when the risk score is high. Stick with a simpler flow when the product cannot operate a durable store or review queue; adding a distributed rate limiter without ownership and alerting creates more uncertainty than it removes.
Evaluation and rollout checklist
I run the policy in a small eval harness before wiring it to a real sender. The cases include duplicate resends at the cooldown boundary, five wrong codes, a correct code after four wrong ones, clock skew, duplicate provider callbacks, and a suppression arriving between list import and enqueue. Assert both the decision and the emitted reason code.
Roll out behind a feature flag. Compare challenge completion and support contacts against the existing flow, then inspect p95 delivery latency by country. Token cost is not the main concern here, but prompt-driven support tooling still benefits from compact, stable reason codes instead of dumping full event payloads into an LLM context.
The architecture is intentionally modest: a challenge store, a sender queue, a suppression table, and metrics. That is enough to make resend behavior predictable and to keep invalid media recipients out of campaigns without confusing delivery state with identity state.













