Short answer: for an SMS OTP login, let the browser poll your own short-lived challenge record while a server-side worker or delivery callback updates transport status; never make login success depend on a carrier delivery receipt. The six controls that matter are an expiring challenge, a hashed code, one-time verification, bounded attempts, idempotent status updates, and a fallback path. This arrangement fits a Next.js UI with a Node.js boundary, but the state machine matters more than the framework.
I've been paged by missed jobs and duplicate deliveries in production queue systems. The incident lesson transfers directly to an edtech checkout: once payment settles, a receipt job and a phone-auth challenge both cross an asynchronous boundary. I initially treated queue acceptance as evidence that the user-visible action had happened. It isn't. Acceptance, delivery, and consumption are separate facts, so they need separate fields and separate alerts.
Keep those facts apart.
How can SMS OTP delivery status mislead a US or EU 2FA login?
The page should poll a challenge resource owned by the application, not a messaging provider from the browser. That resource can expose a deliberately small view: pending, sent, delivered, delivery_failed, verified, or expired. A verified result means the server accepted the submitted OTP exactly once. A delivered result only means the transport reported delivery; it does not authenticate the person holding the phone.
For a Next.js application, the browser can request a challenge through its normal server boundary, then poll with an opaque challenge ID. The Node.js layer can delegate sending and verification to an internal service. Keep provider credentials, the phone number, the OTP digest, raw callback payloads, and attempt counters out of the browser response. The example below uses Go because the contract is easier to see without framework plumbing; the same states can sit behind a Next.js route handler.
| State | Meaning | Browser action | Server action |
|---|---|---|---|
pending |
Challenge stored; send may still be queued | Wait | Dispatch once |
sent |
Messaging service accepted the send | Show code input | Await transport update |
delivered |
A delivery receipt was observed | Still require the code | Record the update |
delivery_failed |
Transport reported a terminal failure | Offer an allowed fallback | Stop retrying this send |
verified |
Correct code consumed the challenge | Establish the authenticated session | Reject later reuse |
expired |
Verification window ended | Start a new challenge | Reject the old code |
I'm not sure every carrier and route will produce the same useful receipt detail; your mileage may vary by country, handset state, and messaging path. That uncertainty is why the authentication decision must rely on code verification rather than transport telemetry.
How do two state machines keep delivery polling honest?
Two state machines prevent an operational signal from becoming a security decision. The authentication machine owns created -> verified and created -> expired. The delivery machine owns transport observations such as accepted, delivered, or failed. They share a challenge ID, yet neither is allowed to overwrite the other's terminal state.
This distinction handles awkward ordering. A user may enter the right code before a delayed delivery callback arrives. The verification transaction should mark the challenge consumed and create the session; the later callback may enrich delivery telemetry, but it must not reopen, invalidate, or consume the challenge. Conversely, a delivered receipt cannot move authentication to verified. Duplicate callbacks are routine input to the design, not proof of duplicate sends. Use the message ID plus the normalized callback event as an idempotency key, retain the latest permitted transport transition, and acknowledge repeats without repeating business work.
The invariant is blunt: one challenge can create at most one authenticated session.
A database transaction or atomic compare-and-set should enforce that invariant. Reading verified = false, checking a code, and then writing verified = true in separate unguarded operations leaves a race where two requests can both succeed. The write needs a predicate such as “unconsumed and not expired,” and session creation belongs in the same transaction boundary or behind its own unique key. Hash the OTP at rest, compare it on the server, expire it promptly, and cap attempts. The exact lifetime and attempt count are policy choices; measure completion and abuse rather than copying unexplained constants.
Encode the one-session invariant in Go
The following Go code sketches the service boundary. It omits vendor routes and storage syntax on purpose. Store.Consume is the atomic operation: it can return ErrMismatch, ErrExpired, or ErrConsumed, and only one caller can receive success. Messenger.SendOTP returns a message reference that can later correlate a transport update.
package auth
import (
"context"
"errors"
"time"
)
var (
ErrMismatch = errors.New("code mismatch")
ErrExpired = errors.New("challenge expired")
ErrConsumed = errors.New("challenge already consumed")
)
type Challenge struct {
ID string
PhoneE164 string
CodeHash []byte
ExpiresAt time.Time
}
type Store interface {
Create(context.Context, Challenge) error
AttachMessage(context.Context, string, string) error
Consume(context.Context, string, []byte, time.Time) error
ApplyDeliveryEvent(context.Context, string, string, string) error
}
type Messenger interface {
SendOTP(context.Context, string, string) (messageID string, err error)
}
type Service struct {
store Store
messenger Messenger
now func() time.Time
}
func (s *Service) Send(ctx context.Context, c Challenge, code string) error {
if err := s.store.Create(ctx, c); err != nil {
return err
}
messageID, err := s.messenger.SendOTP(ctx, c.PhoneE164, code)
if err != nil {
return err
}
return s.store.AttachMessage(ctx, c.ID, messageID)
}
func (s *Service) Verify(ctx context.Context, challengeID string, candidateHash []byte) error {
return s.store.Consume(ctx, challengeID, candidateHash, s.now())
}
func (s *Service) RecordDelivery(ctx context.Context, messageID, eventID, status string) error {
return s.store.ApplyDeliveryEvent(ctx, messageID, eventID, status)
}
The delivery-event operation should reject unknown message IDs, validate the callback using the messaging service's documented mechanism, and deduplicate eventID. It should also constrain transitions. For example, a late sent event must not downgrade an already recorded delivered state. Don't log the OTP or include it in tracing attributes. Logs need challenge IDs, message IDs, transition names, durations, and coarse destination regions; access to phone numbers and callback bodies should be limited according to the system's data policy.
There is a deliberate gap between storing a challenge and attaching the message reference in this small example. In a production design, an outbox record written with the challenge closes that queue handoff gap: a worker claims the outbox item, sends once under an idempotency key where the transport supports one, and records the reference. If a process exits after the send but before recording it, blind retry can produce a duplicate SMS. The runbook should treat that ambiguity as an explicit state and reconcile by the stable send key or message reference, rather than pretending the network can provide exactly-once effects.
Operate US and EU phone auth as separate delivery lanes
Normalize numbers to an international form before sending, preserve the originally entered value only if there is a defined product need, and don't infer a person's residence or consent from a country calling code. US and EU are labels for test matrices, not routing guarantees. Local sender rules, consent obligations, filtering, and supported sender identities can differ, so confirm current requirements with the chosen messaging service and legal owner before launch. This is one place where I'm not sure a generic rule can stay accurate: the answer depends on destination, sender type, use case, and current regulation.
Test with controlled numbers in every destination lane you intend to support. Cover the normal path, an expired code, a wrong code, repeated verification, delayed status, duplicate status, status reordering, resend, and fallback. The test should assert user-visible behavior and stored transitions, not merely that a mock send method ran. For an edtech flow, also test that settling a payment can enqueue one receipt independently of whether a later phone-auth SMS succeeds; coupling those delivery paths turns a messaging delay into a checkout incident.
Observability needs three different rates: send acceptance, transport outcome, and verification completion. Segment them by coarse destination and route without putting phone numbers in metric labels. Alert on a sustained change against the lane's own baseline, because a global average can hide a regional failure. Also track challenge creation to verification latency, retries per challenge, expired challenges, duplicate callback count, and fallback use. A transport dashboard answers “did messages move?” while an authentication dashboard answers “could users sign in?”
Short tests catch long pages.
A deployment should first exercise synthetic or controlled destinations, then expand while watching both state machines. Keep a rollback path for application changes and a routing switch for the messaging layer. The operational goal isn't to make every callback arrive in order. It is to keep the authentication invariant true when they don't.
Put polling limits in the runbook
Polling is suitable when the browser needs a simple view of a short-lived challenge and the interval is bounded. Return a nonterminal state with a suggested delay, add jitter, stop after verification or expiration, and avoid exposing provider details. A 202 Accepted response can represent a challenge still in progress, while 200 OK can return its current representation; the precise HTTP contract should be documented and tested consistently. Aggressive subsecond polling wastes capacity and can amplify a login surge, so the client should back off and the server should rate-limit by more than one easily rotated identifier.
The catch is that delivery polling is not suitable as the gate for access. Stick with direct OTP submission as the authentication action, or choose another authenticator when SMS cannot meet the threat model, accessibility needs, regional availability, or recovery requirements. NIST SP 800-63B treats use of the public switched telephone network for out-of-band authentication as restricted and calls for alternative authenticators to be available; teams with higher assurance needs should evaluate phishing-resistant options rather than adding more polling.
SMS also may be the wrong fallback when the user has no reliable mobile coverage, cannot receive the chosen sender type, or needs an accessible non-phone path. Email is not automatically interchangeable: sender authentication and reputation have their own operational requirements, reflected in Google's email sender guidelines. Pick fallback channels through a threat model and support plan, not because another API is nearby.
For teams, the trade-off is ownership. A richer in-house state machine gives precise audit and failover behavior, but it adds schema migrations, callback verification, regional testing, abuse controls, on-call dashboards, and reconciliation work. A managed verification layer may absorb some transport policy, while the application still owns session issuance and account recovery. Neither choice removes the need to test terminal states and document who responds when verification completion drops.
Top comments (0)