DEV Community

ZorvynGale1729
ZorvynGale1729

Posted on

SMS 2FA Login Backend Flow — Reconciling Delivery Status Under Uncertainty

Build the SMS 2FA login backend flow around an evidence ledger, then poll for missing delivery status on a bounded schedule. The deciding constraint is reliability under partial failure: a timeout cannot tell you whether an OTP message with a signup verification link was rejected, accepted, or merely slow to report.

TL;DR: commit the verification challenge and delivery intent before dispatch, give every attempt one stable identity, apply notifications idempotently, and poll only while the result can still change an operator or user action. Never interpret a delivery receipt as proof that the person verified the account. Only successful consumption of the single-use link does that.

This distinction is the whole design. A developer signing up for a tool cares that the verification link arrives and works. On-call needs a different answer: what did the backend attempt, what evidence returned, and can another action improve the outcome without producing a duplicate? Mixing those questions creates the worst kind of incident: the dashboard says "delivered" while signup is blocked, or a retry sends a second message after the first one was already accepted.

How should a backend flow poll SMS 2FA login delivery status?

Start with four facts, stored independently: a challenge was created, a delivery was requested, transport evidence was observed, and the challenge was consumed. They happen on different clocks. They can also arrive out of order.

The challenge should follow the security properties OWASP specifies for password-reset tokens: generate it with a cryptographically secure mechanism, store it securely, expire it, and allow one use. The same properties fit an account-verification link even though the surrounding workflow is signup rather than password recovery. Routine logs should identify the challenge and attempt without containing the secret, the full URL, or the full phone number.

Account state changes only when the challenge is consumed successfully. A transport notification cannot cross that boundary. It may say that a carrier-facing operation reached a terminal state; it does not prove that the intended person received, opened, or used the link.

Keep the delivery vocabulary small and local. planned means the application committed an intent. submitted means dispatch returned evidence of acceptance. confirmed and failed are terminal transport observations. unknown means the evidence window closed without a trustworthy terminal result. That final state is not failure dressed up in softer language. It is an admission that the system cannot prove more.

One uncomfortable case drives the model: dispatch times out after the downstream system may have accepted the request. Retrying immediately can produce a duplicate. Marking it failed invents certainty. The correct write is unknown, followed by reconciliation using the same attempt identity if the transport interface supports a status lookup. This is how the backend should handle a failed-looking OTP send: preserve what it knows, preserve what it doesn't know, and ask for evidence before creating another external effect.

Ambiguity is a state.

Use one ledger for callbacks and scheduled checks

Callbacks and polling are two ways to acquire evidence about the same attempt. They should converge on one row and one transition function, not maintain separate versions of truth. The row needs an attempt ID, challenge ID, current transport state, provider-side reference when available, evidence timestamp, next check time, and a hard reconciliation deadline. A Node and Express edge can create that row, but it shouldn't own the clock: after the HTTP response, a durable worker and database must still be able to explain and continue the flow.

The callback path authenticates the notification using the documented mechanism for the selected transport, parses it, deduplicates it by a stable event identifier when one exists, and applies an allowed transition. The scheduled path claims attempts whose next_check_at has passed, performs a lookup, and feeds the normalized observation through that exact transition code.

Here is the core state rule in Go. It deliberately refuses to make ordering depend on the order in which network events arrive:

package delivery

import "errors"

type State string

const (
    Planned   State = "planned"
    Submitted State = "submitted"
    Confirmed State = "confirmed"
    Failed    State = "failed"
    Unknown   State = "unknown"
)

var errInvalidTransition = errors.New("invalid delivery transition")

func Advance(current, observed State) (State, error) {
    if current == observed {
        return current, nil
    }
    if current == Confirmed || current == Failed || current == Unknown {
        return current, errInvalidTransition
    }

    switch observed {
    case Submitted, Confirmed, Failed, Unknown:
        return observed, nil
    default:
        return current, errInvalidTransition
    }
}
Enter fullscreen mode Exit fullscreen mode

Persist the observation and state change in one database transaction. A duplicate event then becomes a no-op, while a late nonterminal observation cannot move a terminal attempt backward. Record rejected transitions as telemetry. They are useful evidence of delayed or reordered notifications, and silently discarding them makes a postmortem unnecessarily speculative.

There is a trade-off in terminal unknown. It prevents endless checks and makes uncertainty visible, but it also means reports must not lump unknown into failed. That extra category costs a little dashboard complexity. It is still cheaper operationally than pretending that absence of evidence is evidence of rejection.

Do not send again.

How long should reconciliation keep running?

Derive the schedule backward from the application deadline, not forward from a convenient timer interval. The verification challenge has an expiry chosen by the application. Reconciliation has an earlier cutoff: the latest moment when learning the transport result could still justify a safe user-facing action. Once that cutoff passes, more polling can improve an audit trail but cannot rescue this signup.

Use a short first delay, increasing later delays, and jitter. The exact values are policy, not standards. For example, a team might test a sequence of 15 seconds, 45 seconds, and 2 minutes in staging, then change it after observing its own transport latency and signup completion data. Those numbers are an illustrative test configuration, not a claim about carrier timing.

Stop on the first terminal observation, when the reconciliation cutoff arrives, or when the challenge expires. Stop means stop. A loop with no deadline converts a temporary evidence gap into permanent dependency load.

The worker also needs a renewable claim with an expiry. If a process dies after selecting work, another worker must eventually reclaim it. If two workers race, the database update should allow only one claim. In-memory timers cannot provide either property across restarts, deployments, or a dead process.

After being paged for missed jobs and duplicate deliveries, I treat the retry decision separately from the status-check schedule. The two failure classes push an operator in opposite directions: more aggressive sending may reduce a missed-message count while increasing duplicate messages, while conservative retry can protect recipients but leave a genuine failure unresolved for longer. A status check gathers evidence; a resend creates a new external effect. They don't belong behind the same automatic retry switch, and the runbook should require separate observations before changing either control.

For signup, a resend should normally reference the existing active challenge until that challenge expires or is consumed, subject to the application's abuse controls. OWASP recommends protections against excessive automated submissions in token workflows. Rate limits need to cover both the account identifier and the destination so an attacker cannot use the endpoint to flood someone else's phone.

Make the alert describe a user deadline

Do not page on each failed send. Page when the service is losing its ability to complete verification before the deadline.

The primary signal is the age and number of signup challenges that have an unconsumed link and no trustworthy terminal delivery evidence, grouped by time remaining before the reconciliation cutoff. Pair it with the age of the oldest due, unclaimed ledger row. A growing evidence gap with fresh worker claims suggests the transport path is ambiguous; old unclaimed work points toward scheduling or database access.

Short is useful here. The runbook can branch on three questions:

  • Is due work being claimed? If not, inspect scheduler ownership, database reachability, and claim-query latency.
  • Are claims finishing? If not, inspect worker saturation, lookup timeouts, and expired-claim recovery.
  • Are challenges being consumed? If transport evidence is healthy but verification completion falls, investigate link expiry, message content, and the verification endpoint instead of increasing poll frequency.

Keep denominators beside rates. Zero confirmed failures during a period with zero dispatches is not success. A rising count of delivery confirmations also cannot explain away a falling count of consumed challenges. The ledger should let an operator move from a cohort on the dashboard to the attempt history without exposing the verification secret.

Email fallback deserves its own health boundary rather than being treated as an infallible escape hatch. If the signup flow offers email, follow mailbox-provider sender requirements and measure that channel's acceptance and verification completion independently. Yahoo's sender guidance, for example, documents authentication, complaint, and unsubscribe expectations for mail sent to its users. Those requirements do not turn an email acceptance event into proof of account verification either.

Verify ambiguity before enabling automatic action

Test the unpleasant interleavings first. Freeze the clock at challenge expiry and at the reconciliation cutoff. Deliver the same notification twice. Apply a nonterminal notification after a terminal one. Let a worker lose its claim after the remote lookup completes but before the local transaction commits. Force dispatch to time out after the request may have crossed the network boundary.

The required outcomes are specific: duplicate observations do not change state twice; terminal state never moves backward; an expired claim can be recovered; and an ambiguous dispatch does not automatically create a second message. Also verify that consuming a link succeeds once and that later attempts fail without revealing whether a phone number belongs to an account.

Deploy in observation mode. First commit ledger rows and process notifications while scheduled lookups and resends remain disabled. Then enable reconciliation for a small cohort and compare ledger transitions with challenge consumption. Automatic resend, if the product truly needs it, comes last and gets a separate kill switch.

Rollback should remove effects before removing evidence. Disable new dispatch and resend actions, continue accepting authenticated notifications, stop claiming new reconciliation work, and allow current claims to finish or expire. Do not delete unresolved rows. They preserve the difference between work that was never dispatched, work whose result is unknown, and a challenge that simply was not consumed.

The reliable flow is an evidence system with a deadline. Durable intent survives process loss, idempotent transitions tolerate duplicates, bounded reconciliation covers missing notifications, and only the verification link changes the account. That is enough structure to explain a failed signup without turning every uncertain timeout into another SMS.

References

Top comments (0)