A logistics portal that sends compliance notices should treat SMS OTP as a short-lived authentication ceremony, not as proof that the notice itself was delivered. The low-integration-effort design is a small backend state machine with an adapter at the SMS boundary: generate and store a protected code, enforce resend and verification limits atomically, then emit a separate audit event when an authenticated operator dispatches the notice. This keeps authentication evidence distinct from delivery evidence from day one.
Short answer: keep OTP policy in your backend, keep the SMS provider behind one narrow interface, and make every send, verify, cooldown, and retry decision an atomic state transition.
The distinction matters in logistics. An OTP can establish that someone controlled a phone number during a brief window. It can't establish that a driver read a hazmat-policy update, that a carrier accepted it, or that a downstream handset displayed it. Put those facts in different records. Clean boundaries first.
Two records anchor the logistics workflow
An auditable delivery record needs its own identifiers and lifecycle. Link the successful login event to an operator ID and authentication timestamp, then link the notice command to a notice ID, template revision, recipient, dispatch time, and provider message ID. Delivery callbacks can append accepted, delivered, or failed states when those states are defined by the transport contract. None of those rows needs the OTP, its digest, or the SMS body. This separation prevents an attractive but false inference: otp_verified does not mean compliance_notice_delivered. It also makes retention policy less tangled. Authentication challenge data can expire quickly, while the organization can retain the notice audit record according to its legal and operational requirements. The exact retention period varies by jurisdiction and cargo program; counsel and compliance owners must set it. Phone numbers and notice metadata may be sensitive, so use access-controlled structured events and a correlation ID instead of dumping request objects. A useful event vocabulary is small: otp_send_accepted, otp_verify_rejected, otp_verified, notice_dispatched, and transport-status updates. Metrics should aggregate outcomes without turning high-cardinality phone numbers into labels. A fully managed verification service can reduce integration effort because it may own code generation, storage, expiry, and abuse controls, while a custom policy core gives a team tighter control over audit semantics and evaluation but adds state, concurrency, key management, and on-call responsibility. The custom route is not suitable when the team cannot operate a transactional store and abuse monitoring; use a managed verification boundary in that case. Keep the custom design when policy ownership and audit integration justify that operational load.
Keep those ledgers apart.
The catch is operational ownership: a custom OTP backend is not suitable when the team can't operate transactional state, key rotation, and abuse monitoring. Choose a managed verification boundary for that team; choose the custom policy core only when audit integration warrants the extra responsibility.
How should an SMS OTP backend send, verify, rate-limit, and retry codes?
Model the flow around a challenge identifier, not around a phone number alone. A send request normalizes the destination, checks account and destination budgets, creates or reuses a live challenge, and asks an adapter to deliver the message. A verify request consumes one attempt, compares a protected representation of the submitted code, and either marks the challenge used or returns a generic rejection. A resend is another send transition with a cooldown; it is not a fresh path around the limiter.
For a dispatcher who must sign in before sending a customs-document notice, the useful data flow is: client to authentication API, authentication API to shared state store, authentication API to SMS adapter, and then authenticated notice command to a separate audit log. The browser never decides whether a cooldown has elapsed. The SMS callback, if one exists, updates message-delivery state but does not authenticate the operator.
Use stable public outcomes. 202 can acknowledge an accepted send request without revealing whether an account or number exists. A failed verification can use one generic 401 response for wrong, expired, exhausted, or already-used challenges. 429 is appropriate when the caller must wait before another send or verification attempt. Internally, record precise reason codes such as send_cooldown, destination_budget, and attempts_exhausted; externally, keep the response deliberately dull.
The hard part is concurrency. Two workers can read the same remaining-attempt count and both accept unless the decrement and comparison happen in one transaction or one atomic store operation. The same rule applies to consuming a correct OTP: exactly one transition from pending to verified may succeed. A process-local dictionary is fine for the runnable example below, but it isn't a production coordination mechanism when multiple workers serve traffic.
Implement the narrow policy boundary
This example deliberately leaves transport behind a callable. It gives a notebook or test harness a deterministic clock and sender, while production can inject a real messaging adapter and shared transactional repository. The values are example policy choices, not claims that one set of numbers fits every risk model.
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
import hmac
import secrets
import time
from typing import Callable
class OtpRejected(Exception):
pass
class RetryLater(Exception):
def __init__(self, retry_after: int) -> None:
self.retry_after = retry_after
super().__init__(f"retry after {retry_after} seconds")
@dataclass
class Challenge:
digest: str
expires_at: int
resend_at: int
attempts_left: int
used: bool = False
class OtpService:
def __init__(
self,
pepper: bytes,
send_sms: Callable[[str, str], None],
now: Callable[[], float] = time.time,
) -> None:
self._pepper = pepper
self._send_sms = send_sms
self._now = now
self._challenges: dict[str, Challenge] = {}
def _digest(self, challenge_id: str, code: str) -> str:
payload = f"{challenge_id}:{code}".encode()
return hmac.new(self._pepper, payload, sha256).hexdigest()
def send(self, challenge_id: str, phone: str) -> None:
now = int(self._now())
current = self._challenges.get(challenge_id)
if current and now < current.resend_at:
raise RetryLater(current.resend_at - now)
code = f"{secrets.randbelow(1_000_000):06d}"
self._challenges[challenge_id] = Challenge(
digest=self._digest(challenge_id, code),
expires_at=now + 300,
resend_at=now + 30,
attempts_left=5,
)
self._send_sms(phone, f"Your sign-in code is {code}")
def verify(self, challenge_id: str, submitted_code: str) -> bool:
now = int(self._now())
challenge = self._challenges.get(challenge_id)
if (
challenge is None
or challenge.used
or now >= challenge.expires_at
or challenge.attempts_left <= 0
):
raise OtpRejected("code rejected")
challenge.attempts_left -= 1
supplied = self._digest(challenge_id, submitted_code)
if not hmac.compare_digest(challenge.digest, supplied):
raise OtpRejected("code rejected")
challenge.used = True
return True
sent_messages: list[tuple[str, str]] = []
clock = lambda: 1_800_000_000.0
service = OtpService(
pepper=b"replace-with-secret-key-material",
send_sms=lambda phone, body: sent_messages.append((phone, body)),
now=clock,
)
service.send("login_7f3a", "+15550102020")
code = sent_messages[0][1].rsplit(" ", 1)[1]
assert service.verify("login_7f3a", code)
Don't copy the in-memory store into a multi-worker deployment. Put challenge creation, attempt decrement, cooldown enforcement, and one-time consumption behind a repository that offers conditional writes or transactions. Also replace the illustrative pepper, avoid logging message bodies, and normalize phone numbers before applying destination limits. The provider adapter should receive a destination and rendered body; it shouldn't own authentication policy.
One subtle failure is creating the state and then losing visibility when the delivery call times out. Assign an idempotency key to the send operation and persist an attempt record before invoking the adapter. A retry should refer to that attempt rather than minting unlimited new challenges. Whether the adapter can safely deduplicate that key depends on its contract, so I'm not sure a universal retry count exists; resolve it with the provider's documented idempotency behavior and your own delivery telemetry.
Rehearse races and recovery before release
A happy-path unit test proves almost nothing. Turn the requirements into state transitions and test them with a fake clock: the first send succeeds, an immediate resend returns a retry delay, a resend after cooldown succeeds, a wrong code consumes exactly one attempt, an expired code fails, and a correct code can be consumed only once. Then run the same contract suite against the production repository implementation. This is the notebook-to-prod bridge I care about: one cheap policy harness, two storage implementations, identical assertions.
Add concurrency tests. Race two correct verification requests and assert that one succeeds. Race two resend requests at the cooldown boundary and assert that the send budget moves once. Inject a transport timeout after the attempt record is committed, retry with the same idempotency key, and assert that your adapter contract prevents an uncontrolled duplicate. These tests expose integration risk before a real phone receives a burst of codes.
Prompt and model costs aren't relevant to OTP generation, and introducing an AI call here would add latency and a nondeterministic dependency to a security decision. Keep message templates versioned and deterministic. If an AI system helps draft compliance copy elsewhere, its evaluation results and prompt costs belong to the content pipeline, not to the authentication state machine.
Operationally, watch send acceptance, verification success, rejection reasons, cooldown hits, attempt exhaustion, adapter latency, and callback lag. Alert on changes relative to your own baseline rather than publishing a magic threshold. Run synthetic checks through test destinations where the transport permits them, rotate the OTP pepper under a documented key procedure, and rehearse disabling sends without blocking already-authenticated staff from accessing incident instructions. Short and boring wins.
Before release, walk one correlation ID from login start through OTP verification and notice dispatch, confirming that the audit trail answers who acted, what template revision was sent, to whom, and when. Confirm separately that no secret or message body appears in application logs. Finally, review the 401 and 429 responses from an attacker's perspective: they should enforce the policy without becoming an account-discovery API.
Top comments (0)