A Node.js backend for SMS OTP login should send a code only after an atomic rate-limit decision, then verify it as a single-use challenge. For an edtech platform that reveals an order receipt after payment settles, the service should keep enough telemetry to answer three questions: was a challenge requested, was one dispatch attempted, and did verification reach a terminal result? Those answers require a small state machine, atomic counters, and deliberately low-cardinality events. They do not require storing every provider callback or every digit a learner submits.
TL;DR: issue a random single-use code, store only its keyed digest, enforce cooldown and rate limits atomically, and retry dispatch only when the outcome is known to be safe. Tie the authenticated session to authorization for the settled order; possession of a phone number must never become permission to read an arbitrary receipt. Keep compact outcome events long enough to investigate delivery and abuse, aggregate them before extending retention, and delete request-level detail on schedule.
The observability bill is dominated by multiplication: requests times events per request times encoded event size times retention. Cardinality determines how much extra index and time-series work those events create. Before discussing libraries, I would write the budget as:
stored bytes = challenges × events per challenge × average encoded bytes × retained replicas
Consider a planning model, not a benchmark: 1,000,000 monthly challenges, six retained events per challenge, 600 encoded bytes per event, and two retained copies produce 7.2 GB before compression and index overhead. Doubling retention doubles the stored request-level bytes. Removing phone numbers, receipt IDs, free-form error strings, and provider message IDs from indexed labels attacks the more dangerous term: an unbounded set of distinct label values.
Keep outcomes, not exhaust. A bounded event, channel, country_code, and reason_code vocabulary supports service-level analysis. A phone number does not belong in a metric label. Use a rotating pseudonymous subject key in restricted logs only when investigations genuinely require correlation, and never log the OTP itself.
How should a Node.js backend send an SMS OTP for login?
The API boundary is a pair of commands, but the internal model needs at least six transitions: requested, suppressed, dispatching, dispatched, verified, and terminally failed. The exact names are local. The important property is that a transition and its counter update happen atomically, so two Node.js workers cannot both conclude that a resend is allowed.
A send request first normalizes the phone number, checks account and network limits, checks the resend cooldown, and creates a challenge with an expiry and maximum-attempt policy. Generate the code with a cryptographically secure random generator. Store a keyed digest rather than plaintext, and compare digests in constant time. NIST SP 800-63B treats out-of-band authentication as restricted and discusses replay resistance, rate limiting, and protected channels; an SMS challenge therefore deserves compensating controls and should not be presented as phishing-resistant authentication.
The public request can be intentionally boring: a normalized phone number, a purpose restricted to viewing a settled order receipt, and an idempotency key generated by the client. Return a generic acknowledgement even when an account is absent or temporarily blocked. That reduces account enumeration. Internally, retain the distinction as a bounded reason code, with access controls appropriate to authentication data. The response should expose neither account existence nor the downstream carrier result.
This curl example leaves both paths as deployment configuration because they are application-owned contracts, not standardized routes:
curl --request POST "${OTP_ORIGIN}${OTP_SEND_PATH}" \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: ${OTP_IDEMPOTENCY_KEY}" \
--data '{"phone":"+12025550123","purpose":"view_settled_order_receipt"}'
Verification consumes the challenge on success. On failure it increments an attempt counter in the same atomic operation that evaluates the submitted digest; once the configured ceiling is reached, the challenge moves to a terminal state. A new send must not reset the verification-attempt budget in a way that permits indefinite guessing.
Production logs must redact the submitted code before it reaches application, proxy, tracing, or error-reporting storage.
curl --request POST "${OTP_ORIGIN}${OTP_VERIFY_PATH}" \
--header 'Content-Type: application/json' \
--data '{"challenge_id":"01JEXAMPLE7V6C9Y4Y2V8K3R1","code":"123456"}'
The identifiers and code above are synthetic. The important contract is that the verification response does not echo the code and that the server, rather than the caller, decides whether the challenge remains usable.
No exceptions.
Make retry semantics narrower than failure semantics
Timeout does not mean “message was not sent.” It means the caller lacks an answer. Retrying a timed-out dispatch without an idempotency boundary can send two texts, confuse the learner about which code is current, and inflate both messaging and telemetry volume.
Use one stable dispatch idempotency key for retries of the same challenge. Retry only transient transport outcomes, with capped exponential backoff and jitter, and stop when the challenge would arrive too close to expiry to be useful. A provider rejection, invalid destination, expired challenge, exhausted budget, or policy suppression is terminal for that dispatch. If the downstream interface cannot provide idempotent acceptance, an outbox plus a single logical dispatch record narrows duplication, but cannot prove that an ambiguous remote timeout produced no message. This is a limitation, not an implementation detail: applications that cannot tolerate a delayed or duplicated SMS should choose a different authentication factor rather than hiding uncertainty behind another retry.
Do not rotate the code merely because a client repeats the same request during cooldown. The client should receive the remaining cooldown or a neutral accepted response according to the enumeration policy, while the server keeps one active challenge. Cooldown controls repeated sends; a broader sliding-window or token-bucket limit controls bursts by subject, account, network range, and device signal. Those scopes serve different abuse cases and should not share one counter.
This is also where the receipt boundary matters. Payment settlement should write the order state independently of SMS delivery. After OTP verification establishes a session, authorization must still confirm that the session's account owns the settled order. A failed text cannot roll back a settled payment, and a successful text cannot manufacture ownership.
Count cardinality before choosing retention
An event schema should make cheap questions easy and expensive questions explicit. One compact record can include timestamp, stable event name, challenge age bucket, attempt bucket, country code, transport outcome, latency bucket, and a rotating subject pseudonym. Keep raw phone numbers, message bodies, OTPs, authorization headers, full provider payloads, and receipt identifiers out of the general observability stream.
| Signal | Useful dimension | Avoid as an indexed label | Retention decision |
|---|---|---|---|
| Challenge creation | purpose, coarse country | phone, challenge ID | short request-level window |
| Dispatch | outcome, attempt bucket | provider message ID, raw error | short detail plus aggregates |
| Verification | result, challenge-age bucket | submitted code, session token | security-policy window |
| Receipt access | authorized or denied | receipt contents, order ID | audit-policy window |
The table separates metrics from restricted audit records. Metrics answer rates and distributions with bounded labels. Audit records answer who accessed a receipt, but they need tighter access, a stated purpose, and their own deletion policy. Combining both into one “search everything” index makes routine dashboards depend on sensitive identifiers and turns every additional day of retention into a larger privacy and access-control obligation.
Sampling is asymmetric. Keep every rare terminal security transition if policy requires it, while sampling high-volume successful progress events by a deterministic key so a challenge is either coherently represented or absent. Head sampling each line independently produces broken stories. Preserve unsampled counters for total requests, suppressions, dispatch outcomes, verification outcomes, and latency buckets; sampled traces then explain shape without pretending to be totals. The trade-off is blunt: deterministic sampling preserves coherent examples but can miss a small failure cluster if its keys fall outside the sample, while retaining every trace improves forensic coverage at a storage and access-control cost that grows directly with traffic and retention.
Measure first.
There is a real cost to deletion. Once request-level detail expires, an investigator may know that failure rates rose in one country but be unable to reconstruct a particular learner's sequence. That loss is deliberate. Retain aggregate counts longer, keep narrowly authorized audit evidence only for its defined purpose, and document the point after which individual reconstruction is impossible.
Operate the flow without recording the secret
Test concurrency, ambiguity, and expiry rather than only the happy path. Two simultaneous send requests should create at most one active logical dispatch. Two simultaneous correct verifications should yield one successful consumption. A transport timeout followed by retry should reuse the dispatch key. Advancing a test clock past expiry should make both dispatch and verification stop.
Deployment deserves the same caution. Add new event fields as optional, deploy readers before writers when changing stored state, and keep reason codes backward compatible through a rolling release. Alert on ratios derived from bounded counters: suppressions per request, accepted dispatches per attempt, successful verifications per active challenge, and authorization denials per authenticated receipt request. Do not page on raw log volume alone.
The final review is short: can an operator diagnose a country-level delivery shift, an abuse burst, an idempotency defect, and an authorization denial without searching a phone number or seeing a code? If yes, the retained data is probably doing work. If no, adding unrestricted payloads is still the wrong first move; add one bounded field or one protected audit event that resolves a named question.
What do we stop keeping? Full callback bodies, per-attempt free-form errors, raw identifiers in general logs, and successful request traces beyond the short diagnostic window. We accept that some old, individual delivery disputes will remain unresolved. The alternative is indefinite sensitive-data storage whose cost, cardinality, and access surface grow together.
Further reading
- NIST SP 800-63B, Digital Identity Guidelines: Authentication and Lifecycle Management: https://pages.nist.gov/800-63-3/sp800-63b.html
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (relevant when the settled-order receipt also has an email delivery path): https://datatracker.ietf.org/doc/html/rfc7489
Top comments (0)