For a media login that sends an SMS one-time password, keep the flow asynchronous: create one challenge, record provider delivery events, and let the client poll a small status resource until the challenge is accepted, expired, or failed. The deciding constraint is integration effort, so the backend should expose one stable state machine while adapters absorb provider-specific callbacks and response formats.
Short answer: treat “sent,” “delivered,” and “failed” as evidence with different trust levels, never as interchangeable booleans. A login attempt should expire after a short window, accept an OTP only once, and avoid revealing whether a phone number is registered.
The invariants and failure boundaries
The media application needs four invariants. A challenge has a server-generated random code, a creation time, an expiry time, and a consumed marker. The code is stored as a verifier (a keyed digest or a slow password hash), not as readable text. A retry creates a new challenge and invalidates the previous one; otherwise an old SMS can authenticate a newer browser session.
Delivery is a separate fact. A provider may acknowledge a request before a carrier delivers it, and a carrier receipt may arrive after the user has already requested another code. Give every attempt an idempotency key and persist the provider message identifier, event timestamp, and raw event type. Apply events monotonically: a late “delivered” event must not resurrect an expired or cancelled challenge. In practice, that means the event consumer loads the challenge row under a transaction, compares the event's attempt ID and timestamp with the current record, and commits only an allowed transition; an event for an older attempt is retained for audit but cannot change the state shown to the browser. This extra write is cheaper than asking support why a code from ten minutes ago suddenly appears valid, and it gives incident review a concrete chain from send command to carrier receipt.
The boundary that matters is authentication, not transport.
A delivery failure should produce a useful retry response, but it must not disclose account existence. Rate-limit by account, phone hash, IP, and device signal; OWASP also recommends uniform responses and bounded reset attempts for recovery flows.
How should a Node Express backend poll SMS 2FA delivery status?
Polling is a read model, not a second send path.
The client receives a random challenge ID and calls a status endpoint every few seconds with an exponential backoff cap. The server returns a deliberately small vocabulary: pending, delivered, failed, expired, or verified. It should never return the OTP, provider payload, or a different message for an unknown account.
Here is the critical path in Python-like pseudocode; the same boundaries map cleanly to an Express handler without coupling the UI to a particular SMS API.
def get_delivery_status(challenge_id, viewer):
challenge = store.find_challenge(challenge_id)
if challenge is None or not safe_to_view(challenge, viewer):
return {"status": "pending"}, 200
if challenge.consumed_at is not None:
return {"status": "verified"}, 200
if clock.now() >= challenge.expires_at:
store.mark_expired(challenge.id)
return {"status": "expired"}, 200
return {"status": challenge.delivery_state}, 200
The odd-looking pending response for an unknown ID is intentional. It keeps enumeration from becoming a login oracle, while logs can still record a 404-like internal reason. Your mileage may vary on the polling interval: carrier latency, client battery policy, and regional traffic should drive the cap, and I would validate those assumptions with production telemetry rather than a synthetic benchmark.
Options and their trade-offs
| Option | Integration effort | Failure visibility | Operational cost | Best fit |
|---|---|---|---|---|
| Webhook plus local status table | Medium | High when signatures and retries are handled | Low request volume | Teams able to run a public callback endpoint |
| Provider status polling | Low to medium | Depends on provider retention and rate limits | More outbound reads | Early systems with no inbound endpoint |
| Queue-driven reconciler | High | Strong audit trail and replay | Queue and worker operations | High-volume media platforms with strict audit needs |
The simplest design is not always the safest. A webhook needs signature verification, replay protection, and a retry policy; a poller needs a bounded schedule and a clear answer for “unknown.” A queue can replay events, but it adds another durable system whose retention and ordering rules must be tested. Pick the smallest option that can preserve the invariants.
Failed sends, retries, and the rejected shortcut
Do not retry every failure. Classify permanent numbers, temporary carrier congestion, provider throttling, and local validation errors separately. A temporary failure can receive one delayed retry under the same login attempt; a permanent failure should end the attempt and invite the user to choose an allowed recovery method. Record a reason code that operators can aggregate without storing message content.
I initially thought a single sms_sent flag would be enough. It was not: a request timeout can mean “accepted,” “rejected,” or “accepted but response lost,” so blindly retrying can deliver two valid-looking codes. The fix is an idempotent send command, a durable outbox, and a reconciliation worker that treats the provider message ID as evidence rather than proof of authentication.
The rejected shortcut is client-side polling of a vendor endpoint. It saves a backend route, but it leaks credentials, makes rate limits part of the user experience, and leaves no authoritative place to expire a challenge. It is suitable only for a tightly controlled internal tool where the credential is not exposed to untrusted browsers; it is a poor fit for a public media login.
Keep delivery status out of the access decision. A user can enter the correct code before a carrier receipt arrives, and a receipt can be forged or delayed if webhook verification is weak. The access decision should be the one-time verifier, expiry, attempt counter, and session binding; delivery state is supporting telemetry.
Top comments (0)