Short answer: create one durable send record before calling the SMS provider, attach one OTP attempt to that record, poll with a bounded schedule until the provider result maps to a terminal internal state, and never issue a replacement code merely because delivery is still pending. For an edtech portal that gates a compliance notice behind 2FA, this is the simplest flow that keeps integration effort modest without sacrificing an auditable account of what the system requested, what the carrier path reported, and what the learner actually proved.
A delivery receipt is evidence about transport, not authentication. A successful OTP verification is evidence that the claimant possessed the receiving device at that moment, not proof that a compliance notice was read. Keep those claims separate in both the schema and the audit trail.
How should a simple SMS 2FA login backend poll delivery status and handle failed OTP sends?
Start with an exactly-once mindset, even though neither HTTP nor SMS offers exactly-once delivery. The practical substitute is an idempotent command boundary: one logical login challenge gets one stable attempt identifier, concurrent request handlers converge on the same row, and every external observation becomes an append-only event. The flow has five transitions, but only one of them sends a message.
- Accept the login challenge and derive an idempotency key from the account, challenge, and purpose.
- Insert the send intent and its OTP digest in one database transaction. Reject or reuse a duplicate key.
- Dispatch once, record the provider message identifier, and move the attempt from
createdtosubmitted. - Poll only attempts whose next-check time has arrived; normalize the provider result to
pending,delivered, orfailed. - Permit verification while the code is valid, independently of whether the latest transport observation says
delivered.
That fifth point matters. Delivery telemetry can lag behind the handset, so making delivered a prerequisite for accepting the correct code can reject a legitimate learner who already received the text. Conversely, a delivery receipt must never mark the login challenge verified. The two state machines meet at an attempt identifier, but one must not drive the other.
The internal record can stay small:
package otp
import "time"
type DeliveryState string
const (
Created DeliveryState = "created"
Submitted DeliveryState = "submitted"
Pending DeliveryState = "pending"
Delivered DeliveryState = "delivered"
Failed DeliveryState = "failed"
Expired DeliveryState = "expired"
)
type Attempt struct {
ID string
ChallengeID string
IdempotencyKey string
ProviderMessageID string
State DeliveryState
CodeDigest []byte
ExpiresAt time.Time
NextPollAt time.Time
PollCount int
}
Store a keyed digest of the OTP rather than the code itself, restrict attempts, and return a consistent response for account-related requests. Those controls follow the same principles OWASP applies to reset tokens and PINs: cryptographically secure generation, linkage to a single user, invalidation after use, secure storage, expiration, and rate limiting. The login use case isn't identical to password recovery, so the exact lifetime and attempt ceiling belong in a threat model rather than in a copied constant.
No magic here.
Should status polling leave the login request path?
Polling inside an Express request handler looks simple until a process restarts, a reverse proxy times out, or two browser retries create two loops. Put reconciliation in a worker that claims due rows from durable storage. The API request should create or retrieve the attempt and return promptly; a status endpoint should read your normalized record, never trigger another provider query on demand. This keeps the user-facing latency independent of downstream status latency and makes the workload measurable.
Which delivery state must survive a worker restart?
A compact polling policy might check soon after submission, then increase the delay and stop at the OTP expiry time. Those are policy choices, not universal timing facts. Your mileage may vary because provider status semantics, carrier paths, and regional rules differ; production values should come from observed latency distributions and the provider contract. Add jitter so a worker restart doesn't release a synchronized burst.
package otp
import (
"context"
"errors"
"time"
)
type ProviderStatus string
const (
ProviderPending ProviderStatus = "pending"
ProviderDelivered ProviderStatus = "delivered"
ProviderFailed ProviderStatus = "failed"
)
type StatusClient interface {
Lookup(ctx context.Context, messageID string) (ProviderStatus, error)
}
type Repository interface {
Due(ctx context.Context, now time.Time, limit int) ([]Attempt, error)
RecordObservation(ctx context.Context, id string, state DeliveryState, at time.Time) error
ScheduleNextPoll(ctx context.Context, id string, at time.Time) error
}
func Reconcile(ctx context.Context, repo Repository, client StatusClient, now time.Time) error {
attempts, err := repo.Due(ctx, now, 100)
if err != nil {
return err
}
for _, attempt := range attempts {
if !now.Before(attempt.ExpiresAt) {
if err := repo.RecordObservation(ctx, attempt.ID, Expired, now); err != nil {
return err
}
continue
}
status, err := client.Lookup(ctx, attempt.ProviderMessageID)
if err != nil {
return errors.New("delivery status lookup failed")
}
switch status {
case ProviderDelivered:
err = repo.RecordObservation(ctx, attempt.ID, Delivered, now)
case ProviderFailed:
err = repo.RecordObservation(ctx, attempt.ID, Failed, now)
default:
next := now.Add(backoff(attempt.PollCount))
if next.After(attempt.ExpiresAt) {
next = attempt.ExpiresAt
}
err = repo.ScheduleNextPoll(ctx, attempt.ID, next)
}
if err != nil {
return err
}
}
return nil
}
func backoff(pollCount int) time.Duration {
delays := []time.Duration{5 * time.Second, 15 * time.Second, 45 * time.Second, 2 * time.Minute}
if pollCount >= len(delays) {
return 5 * time.Minute
}
return delays[pollCount]
}
The example uses a batch size of 100 and explicit delays only to make the control loop concrete; they are starting hypotheses, not performance claims. A real worker also needs row claiming or compare-and-swap updates so two replicas cannot reconcile the same attempt concurrently. Provider lookup errors should be retried under a separate operational policy, with a recorded reason and bounded alerting, while the public login response remains non-enumerating. Don't translate an ambiguous lookup into failed, and don't erase the last known state.
Webhooks can reduce status-query traffic and shorten observation latency. Even then, retain reconciliation: verify webhook authenticity according to the provider contract, deduplicate each event, accept events out of order, and periodically poll nonterminal records that have not received an update. A webhook is another delivery channel, not a transaction commit.
Should transport and authentication share one failed state?
A single failed boolean destroys the distinction an operator needs during an incident review. Use separate outcomes for the send attempt, the delivery observation, the OTP verification, and the notice-access event. The audit log should answer four different questions: did the backend authorize a send, did the messaging path report a terminal result, did somebody submit the correct code within policy, and did the authenticated session fetch the compliance notice?
| Event | What it establishes | What it does not establish |
|---|---|---|
otp.send_requested |
The backend created an authorized intent | That an SMS left the provider |
otp.delivery_observed |
A normalized transport status was recorded | That the recipient controlled the phone |
otp.verification_succeeded |
A valid code was presented under the attempt policy | That the notice was opened |
notice.accessed |
An authenticated session requested a specific notice revision | That the learner understood it |
Each event needs an immutable event identifier, attempt or challenge identifier, event time, actor category, normalized outcome, and policy version. Avoid putting the OTP, full phone number, or message body into the audit payload. Retention and access controls are compliance decisions: the correct period depends on the institution's jurisdiction, contractual duties, and data-classification policy, and the public OWASP guidance does not choose it for you.
Failure recovery should also remain explicit. If transport reaches a terminal failure before expiry, show the learner a neutral option to request a new attempt under the same rate limits; do not silently send a second code. A newly authorized attempt gets a new code and identifier, while the old attempt remains closed and auditable. If delivery is merely pending, let the learner wait or choose an already-enrolled alternative factor. Repeated button presses should return the active attempt rather than multiply sends.
This is where 429 deserves deliberate treatment. Rate limiting can apply by account, destination, session, IP risk, and challenge purpose, but the client response must not disclose whether an account or phone number exists. Record the policy decision internally with a correlation identifier, then give the caller a stable, generic response. A 400 should be reserved for malformed input, not used as a catch-all for a denied or expired challenge.
Should integration effort include migration and on-call ownership?
Integration effort is more than the first successful send. Compare options by the code and operational ownership they create across the full notice-access workflow.
A direct provider integration is reasonable when one messaging route is enough, the team can maintain the provider-specific status adapter, and the provider contract supplies the authentication and delivery evidence the audit model requires. An abstraction layer can reduce application coupling when multiple routes or providers are already a firm requirement, but it adds another semantic mapping: the team must prove that normalized states don't hide terminal reasons needed for support or compliance review. A self-hosted gateway offers control over routing and data placement, while placing queue operations, upgrades, sender configuration, and on-call ownership on the institution.
The catch is that polling is not suitable when the provider lacks a queryable message identifier or a documented status contract. Prefer authenticated callbacks plus a timeout reconciler when callbacks are well specified; stick with a direct adapter when the abstraction cannot preserve idempotency keys, raw event provenance, or reason codes. And SMS itself should not be the only factor for high-risk access if the threat model requires phishing resistance or reliable delivery in regions where the messaging path cannot meet the service objective.
No vendor label resolves those constraints. Ask candidates to demonstrate the same acceptance test: submit one stable idempotency key twice, observe a single logical attempt; replay a status event, observe one audit event; deliver events out of order, preserve a legal transition; expire the OTP, reject later verification; request a replacement, close the old attempt without deleting its history. Then test deployment behavior by stopping a worker between lookup and commit. The system should recover from durable state.
How can one notice cohort expose rollout risk?
Roll out narrowly. Start with one notice type and a small internal cohort, shadow the reconciler without changing user-visible decisions, compare provider observations with internal transitions, and alert on attempts stuck beyond the chosen status window. Before expanding, review redaction, retention, clock handling, queue depth, retry budgets, and the operator procedure for a learner who cannot receive SMS. Only after those controls hold should the same adapter serve additional login purposes.
Top comments (0)