An e-commerce support agent cannot route a contact form while locked out, so a 2FA login fallback from SMS to email OTP must restore availability without accidentally multiplying credentials. Polling yields delayed evidence, and email is a second security channel rather than a harmless resend.
TL;DR: Keep one durable login challenge across both channels. Poll SMS only within a fixed observation window, then use one atomic state transition to authorize email; retries reuse the same logical delivery identity, while verification, expiry, and fallback decisions go into an audit trail. The practical reliability boundary is the database commit, not a carrier status.
This choice does not make network delivery exactly once. It makes the decision reproducible when a worker restarts, a request times out after remote acceptance, or an agent clicks again. For a US and EU SaaS with no webhook receiver, that narrower guarantee is the one the application can actually enforce.
How should a 2FA login fallback move from SMS to email OTP?
A polled status is an observation about transport, not evidence that a person received a code. No result is even weaker: it may mean the message is pending, the status service is unavailable, or the observation arrived too late. A fallback rule that translates every timeout into an email send therefore turns missing evidence into additional credentials.
Define a local observation budget instead. As an illustrative policy, a challenge might remain valid for ten minutes while SMS gets a 90-second observation phase with polls after 5, 15, 35, and 70 seconds. Those values are policy inputs, not industry constants; the team must choose them from its authentication risk, queue-access objective, and polling capacity. Store the deadline and policy version on the challenge so a restart cannot silently reset the clock.
The trade-off is concrete: a longer window gives late SMS evidence more time to arrive but keeps the agent away from the contact queue; a shorter window restores access sooner but admits more email attempts while the SMS outcome is unresolved. A four-poll schedule also creates four status reads per unresolved challenge, which the capacity plan must treat as designed load rather than background noise. This polling design is not suitable when the transport has no correlated status lookup, when status reporting routinely exceeds the login budget, or when the service cannot sustain the planned read volume. An authenticated event receiver or an explicit user-initiated recovery flow is a better boundary in those cases.
The state progression is small: sms_pending can become sms_observed, email_admitted, or expired; either delivery path can lead to verified, but only verification of the submitted code authenticates the agent. Unknown provider values remain unresolved and are retained as raw evidence. They must not default to failure.
Stop guessing.
Uncertainty remains.
Put the invariant before the adapters
The invariant is more useful than a feature comparison: one challenge owns the verifier, attempt counter, expiry, identity binding, and current policy; delivery attempts merely carry that challenge through a channel. OWASP's forgot-password guidance says tokens should be randomly generated, linked to an individual user, invalidated after use, stored securely, and protected against excessive requests. The same controls belong in this OTP flow even though the business action is queue access rather than password recovery.
Do not log the plaintext OTP, email address, phone number, or message body. Store a protected verifier and a destination fingerprint where correlation is necessary. The audit record should answer who initiated the challenge, which policy evaluated it, what evidence was present, which transition won, and when it happened; retention and access must follow the organization's approved US and EU privacy controls.
The channel switch belongs in a transaction with an outbox insert. A compact Go boundary makes the intended guarantee visible:
package login
import (
"context"
"time"
)
type EmailAdmission struct {
ChallengeID string
PolicyVersion string
ObservedAt time.Time
Reason string
}
type ChallengeStore interface {
// AdmitEmail changes state and inserts one outbox record atomically.
AdmitEmail(context.Context, EmailAdmission) (bool, error)
}
func EvaluateDeadline(
ctx context.Context,
store ChallengeStore,
challengeID string,
policyVersion string,
now time.Time,
) error {
_, err := store.AdmitEmail(ctx, EmailAdmission{
ChallengeID: challengeID,
PolicyVersion: policyVersion,
ObservedAt: now,
Reason: "sms_observation_window_elapsed",
})
return err
}
Two pollers may call this function. Only one may commit the state change and outbox row; the loser records no second delivery intent. The dispatcher can retry an ambiguous email submission with the outbox item's stable logical key. Exactly once applies to admission, while transport remains at least once and uncertain. Holding a database transaction open across the network call would add latency without resolving a lost acknowledgement.
Make fallback one credential policy, not two messages
The team must decide whether fallback reuses the current code or rotates it. Reuse reduces confusion when a delayed SMS and the email arrive in the opposite order, but exposes the same verifier through two channels. Rotation limits the current verifier to the later decision, but the delayed SMS can contain a code that looks valid and is not. Neither policy wins universally.
What matters is central ownership. A delivery adapter must never mint its own code, extend expiry, or reset the attempt counter. Bind the chosen behavior to a versioned policy, and make the verification transaction consume the challenge once. That preserves a single attempt budget across SMS, email, repeated form submissions, and concurrent workers.
Keep the email strictly transactional. The FTC's CAN-SPAM guide distinguishes commercial content from transactional or relationship content by primary purpose. Mixing promotions into an authentication message changes the compliance analysis and distracts from the security event; counsel should validate the final template and operating context.
Reconcile from the contact queue backward
Delivery dashboards do not prove that agents can route contact forms. Start the operational check at the business boundary: an authorized agent obtained one terminal verification before expiry, and the resulting session can access only its permitted queue. Then reconcile backward through the challenge ledger, channel decisions, outbox entries, dispatch attempts, and polling observations.
The failure matrix should emphasize races rather than happy-path status strings:
| Injected condition | Required result | Evidence to retain |
|---|---|---|
| Two deadline workers run together | One email admission wins | Winning transition and duplicate suppression |
| SMS acceptance response is lost | Retry does not create a new challenge | Stable challenge and logical-send identifiers |
| SMS evidence arrives after fallback | It cannot reopen the channel decision | Late observation linked to the original attempt |
| Verification races expiry | One terminal state commits | Rejected competing transition |
| Agent requests another code | Versioned policy replaces or suppresses it | Actor, policy, and reason |
A scheduled reconciler should find active challenges beyond their decision deadline, aging outbox records, observations with no known attempt, and terminal challenges with pending dispatch work. Repair must call the same idempotent application paths used during normal processing. Direct database edits destroy the audit story precisely when it is most valuable.
Measure time in state, fallback admissions, duplicate suppressions, poll failures, outbox age, expired challenges, and verification outcomes. A higher fallback count does not by itself prove SMS degradation; delayed polling or an unavailable status endpoint can produce the same signal. Alert on violated invariants first, then investigate transport trends.
Roll out the ledger before changing delivery
First add challenge versions, observations, decision reasons, and reconciliation queries while preserving current behavior. Next run the fallback evaluator in shadow mode: record the decision it would have made, but do not enqueue email. Review unresolved status values and confirm that duplicate workers converge on one decision.
Then enable email admission for a deterministic cohort with a kill switch that stops new admissions without erasing history. Expansion should depend on terminal-state conflicts, reconciliation gaps, outbox age, and successful verification, not accepted-message counts. The migration is complete when polling delays, late evidence, retries, and restarts cannot create a second active credential or an unaudited channel change.
That is the useful reliability promise: an agent eventually regains access to the support queue, while every channel escalation remains attributable and replay-safe.
Top comments (0)