Short answer: The best simple backend flow treats an OTP attempt as a short-lived state machine: let Node/Express return an application-owned challenge, cap SMS delivery polling in a worker, and create a fresh challenge after a terminal failure.
In an edtech marketplace, the new-order notification and the seller's 2FA login are separate events. Retain enough evidence to prove what each workflow did without turning every provider callback into permanent, high-cardinality telemetry. This gives compliance reviewers a defensible sequence while keeping the authentication decision independent of the order alert.
The important trade-off is evidence versus collection. Keeping every response body feels cautious, but it expands the set of phone-linked data, raises storage cost, and still may not answer the audit question. A compact transition record usually answers it better: which challenge changed state, why, when, and under which policy version.
What should Node Express poll when SMS 2FA OTP delivery fails?
Node/Express should poll its own challenge resource, while a worker performs a bounded number of status checks against the SMS gateway only when callbacks are unavailable or incomplete. The browser does not need the provider's message identifier, vocabulary, or timing. It needs one stable answer from the application: pending, sent, delivered, failed, expired, or verified.
Keep the two business timelines distinct. A new order can create an order_notice record and request a seller alert. The seller's later login creates an auth_challenge record. Reusing one status field for both is tempting because both may involve SMS, but it destroys the evidence boundary: “order alert delivered” is not proof that the login code was delivered, and neither is proof that the human received or read it.
A minimal client contract can look like this. The endpoints are application-owned examples, not provider routes:
curl --request POST https://api.example.test/auth/challenges \
--header 'Content-Type: application/json' \
--data '{"purpose":"seller_login","destination_ref":"phone_7f3a"}'
curl --request GET https://api.example.test/auth/challenges/ch_01/status
curl --request POST https://api.example.test/auth/challenges/ch_01/verify \
--header 'Content-Type: application/json' \
--data '{"code":"123456"}'
The create response should return the challenge ID, an expiry time, and the first allowed client poll time. The status response should expose the application state and a coarse reason such as provider_rejected or expired, never the OTP itself. Don't let the client select a provider, request an arbitrary poll interval, or turn a failed challenge back into pending.
This split also keeps retry behavior intelligible. A transport retry may inspect the same outbound message; a user resend creates a new challenge, invalidates the prior code, and consumes a rate-limit allowance. OWASP's forgot-password guidance calls for random, securely stored, single-use codes that expire after an appropriate period, along with protection against excessive submissions. The same properties are useful for login OTPs even though account recovery and authentication are different workflows.
How can runtime polling preserve monotonic challenge states?
Start with transitions, because the loop is only a mechanism for learning whether one transition occurred. pending may become sent, delivered, failed, or expired. sent may become delivered, failed, or expired. Only a valid code can produce verified, and terminal states never move backward. A late delivery receipt can be recorded as evidence without reviving an expired challenge.
Short means short.
For example, consider an illustrative policy with a 300-second challenge lifetime, client status checks at 2, 5, 10, and 20 seconds, and no more than four gateway lookups. Those numbers are design inputs, not universal security recommendations. They make the load bounded: 10,000 login attempts can cause at most 40,000 gateway lookups under that policy, rather than an open-ended request stream. Your mileage may vary because delivery receipts and status vocabularies differ; the provider contract and measured latency distribution should determine the actual schedule.
The Node/Express request handler should enqueue the send and return promptly. A worker owns gateway interaction, normalizes external statuses, and writes transitions with an idempotency key. A callback handler, where supported, goes through the same transition function as the polling worker. That shared function matters. Otherwise a delayed callback and a scheduled poll can both observe sent, race, and emit two contradictory terminal records.
Use compare-and-set semantics around the current state, and make duplicate observations cheap. The durable record might hold challenge_id, a pseudonymous destination reference, purpose, state, reason_code, policy_version, created_at, expires_at, and the latest transition time. Provider message IDs often belong in a restricted lookup table rather than general logs. The OTP belongs in neither place as plaintext.
Failure handling should be boring. A terminal delivery failure ends the challenge and permits the UI to offer a new challenge subject to rate limits; expiry does the same without implying a transport fault. A wrong verification code increments a verification counter, not a send counter. Mixing those counters makes incident review almost impossible because an attacker guessing codes and a gateway rejecting messages look like the same event.
OWASP also recommends consistent response messages and timing to reduce account-enumeration signals. That means the public create endpoint should not reveal whether a seller phone number exists. Internally, however, the audit event can preserve the policy decision using a pseudonymous subject reference and access controls. Compliance evidence and public error detail have different audiences.
Count storage cost from evidence events and cardinality
Observability labels are an unbounded bill unless the schema says otherwise. Challenge IDs, phone hashes, provider message IDs, raw error text, and order IDs should not be metric labels. Each can approach one unique value per attempt. Put them in access-controlled event records when investigation requires them; use bounded dimensions such as state, reason family, route, and policy version for metrics.
Bound it.
The retention calculation should be explicit. Let A be daily OTP attempts, E the retained events per attempt, B the average encoded bytes per event after indexing overhead, and D the retention days. The first-order storage estimate is A x E x B x D. At 100,000 attempts per day, four 600-byte events, and 30 days, the event bodies alone are 7.2 GB. This is illustrative arithmetic, not a benchmark; indexes, replicas, and compression can move the actual bill substantially.
Sampling requires a split policy. Never sample away the authoritative state transition ledger while its compliance retention period applies. Sample verbose diagnostic logs instead, and keep aggregate counters for every attempt. If 1% of successful debug traces are retained but 100% of failures are retained, record that sampling policy alongside dashboards so an analyst does not mistake trace counts for traffic counts.
One long paragraph is warranted here because “log everything” fails in several directions at once. Raw gateway payloads can contain destination data and unstable free-form text; indexing that text increases both exposure and storage, putting a unique challenge ID in a metric label creates cardinality proportional to traffic, and retaining repeated pending observations contributes volume without proving a new fact. The better event is a transition, not an observation: one record when the normalized state changes, plus a bounded diagnostic sample for understanding why it changed. If a compliance reviewer needs proof of policy execution, the policy version and transition timestamps are stronger evidence than forty identical poll responses. If an operator needs to debug latency, a histogram with bounded route and outcome labels is more useful than searching millions of challenge-specific time series. Keeping less is deliberate engineering here, not missing instrumentation.
Callbacks and a retry worker have different failure semantics
Callbacks are efficient when the gateway can sign notifications and retries them under a documented contract. They still need idempotency, authentication, replay handling, and a reconciliation job because the application must reason about duplicate and late observations. Gateway polling is reasonable when callbacks are absent or when a short reconciliation window is required, but it should run in a worker with a fixed attempt budget. Client polling is appropriate only against the application's normalized status endpoint.
| Mechanism | Best fit | Evidence benefit | Main limitation |
|---|---|---|---|
| Signed callback | Timely status changes | Preserves provider observation time | Requires a public authenticated receiver and replay policy |
| Bounded worker poll | Reconciliation or no callback contract | Produces controlled, repeatable checks | Adds gateway calls and can learn terminal state later |
| Queue plus scheduled expiry | Every challenge | Centralizes retries and terminal expiry | Requires durable queue operations and idempotent consumers |
No single mechanism proves that a person saw an SMS. A delivery state is transport evidence under a provider's definition; successful OTP verification is application evidence that the submitted code matched an active challenge. Preserve that distinction in field names and audit language.
The catch is that SMS OTP is not suitable when the risk model requires phishing-resistant authentication or when sellers cannot reliably receive SMS. In those cases, choose an authenticator based on stronger cryptographic properties and keep a separately reviewed recovery path. Likewise, stick with callback-led status collection when the provider offers an authenticated, retryable callback and near-real-time status matters; periodic provider polling should remain reconciliation, not the default source of truth.
For the marketplace notification itself, email may be an independent fallback channel, but it needs its own delivery and sender-compliance design. Yahoo's sender requirements cover authentication and complaint-related practices for mail sent to Yahoo recipients. Those requirements do not turn email into an interchangeable OTP transport, and an order-notice outcome should remain separate from the authentication ledger.
Test deployment with evidence checks, not message volume
Deploy the transition function behind a policy version, then replay synthetic sequences such as duplicate callbacks, a poll arriving after expiry, two resend requests, and verification of an invalidated code. The assertions should inspect final state and event count. They should not depend on how many log lines happened to be emitted.
Next, shadow the normalized state against the current flow without changing user-visible outcomes. Compare bounded counters: attempts, terminal outcomes, expiry, resend rate, and transition conflicts. After the new state machine becomes authoritative, shorten diagnostic retention first; preserve the audit ledger according to the approved compliance schedule. Finally, verify that deleting or restricting a destination reference does not break aggregate metrics.
The decision rule is compact: use one immutable challenge identity, one monotonic transition function, a bounded external-status budget, and two retention classes. That is enough to handle failed OTP sends without making a seller login depend on an endless poller or making a new-order notification masquerade as authentication evidence.
References
- OWASP, “Forgot Password Cheat Sheet”: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Yahoo, “Sender Best Practices”: https://senders.yahooinc.com/best-practices/
Top comments (0)