OTP Login Architecture: SMS/Email 2FA Fallback 🔥
Learn OTP login architecture with SMS/email 2FA fallback, recipient suppression, and delivery event polling. Build auditable, reliable flows—start now!

Build an Auditable OTP Login Flow With SMS and Email
A one-time password login can look straightforward from the outside: generate a code, send it by SMS, and fall back to email if the SMS attempt doesn't work. The tricky part is what happens afterward - when messages arrive late, destinations turn out to be invalid, providers retry callbacks, or someone on call has to explain why you sent a second message.
A good beginner setup doesn't need a big event platform or a fancy messaging abstraction. What it does need is clear boundaries. Make the OTP challenge separate from sending the OTP. Treat the SMS and email attempts as attempts tied to the same challenge, but keep their evidence separate. Check suppression before you try a channel. And keep an append-only record of provider evidence - don't treat an "accepted" API call as proof that delivery succeeded.
This guide lays out that design: the smallest useful data model, a replayable polling worker, suppression rules, and alerts that catch mistakes before they turn into a visible fallback spike. The goal isn't to build the most elaborate system. It's to be able to answer a basic operational question with receipts: why did we contact this destination, and what did we know at the time?
Model OTP delivery as a stateful workflow
SMS-primary and email-fallback are often described as a routing rule. That framing misses the real point: fallback is a decision you make after you evaluate the challenge, the result of the first attempt, and whether the second destination is even allowed to be contacted.
It helps to think in three separate objects:
- Challenge: the authentication lifecycle - expiry, consumption, and the policy version used.
- Attempt: one decision to try a specific channel and destination for that challenge.
- Delivery event: evidence you receive later about what happened to that attempt.
This separation avoids a common failure mode: one mutable status field that tries to represent everything at once (challenge state, message request, and provider outcome). An API request can be accepted while delivery later fails. Callbacks can show up twice. Late events can show up after a newer event. If every update overwrites the previous state, you lose the sequence you need to figure out what actually happened.
Keep issuance independent from delivery
Create and validate the OTP challenge in your authentication layer. Store only what you need to verify the code, protect it, and enforce a short lifetime and clear attempt limits. Don't put the OTP itself into delivery events, logs, metrics, or audit records. Delivery evidence should explain the routing decision without turning into yet another place where secrets leak.
When your application decides to send, create an attempt tied to the challenge. Store the channel, a stable internal recipient hash, region (if relevant), the decision reason, and the policy version used. Don't store raw phone numbers or email addresses in metric labels. Those labels get copied into dashboards and alerting systems that often have wider access and longer retention than your authentication database.
Start with a small evidence model
A practical first version uses four record types, plus a polling cursor:
| Record | Purpose | Preserve as evidence |
|---|---|---|
| Challenge | Bind one OTP lifecycle to its policy | Creation time, expiry, policy version |
| Attempt | Represent one channel decision | Channel, recipient hash, region, reason |
| Event | Store externally observed delivery evidence | Provider event ID, raw status, observed time |
| Suppression | Block a destination for a documented cause | Cause event ID, decision time, scope |
| Poll cursor | Resume provider-event ingestion | Last committed position |
Keep these records append-oriented. You can compute a "current state" view from the history, but the historical evidence shouldn't disappear just because the current view changes. That gives security and ops teams a way to investigate disagreements without treating a vendor dashboard as the only source of truth.
This model also makes it clear what you don't know yet. "Request accepted" only means the provider received your API call - it doesn't mean the user got the message. A later bounce, rejection, or timeout event changes what the application can reasonably conclude.
For adjacent decisions, see how to set login rate limits and how to protect account recovery flows. Those controls complement OTP delivery; they don't replace delivery evidence.
Check suppression before every channel attempt
A user account and a delivery destination are not the same thing. A phone number can be invalid even when the account is otherwise fine. Disabling the account because an SMS bounced is usually the wrong move. And automatically sending an email can be wrong too if your policy doesn't actually allow that fallback for this specific challenge.
Keep account eligibility separate from destination eligibility. Suppression should be scoped to a destination and channel, with a documented reason and a defined duration when appropriate. Your routing code should consult suppression before it creates an attempt - not after you already called the provider.
Use an explicit decision sequence
For each challenge, apply a narrow, repeatable sequence:
- Load the challenge and verify it is unexpired, unconsumed, and within the attempt policy.
- Resolve the destination to a stable internal recipient identifier or hash.
- Check suppression for that destination and channel.
- If eligible, create the attempt and record why it was allowed.
- Wait for a policy-defined terminal outcome or deadline before deciding whether to fall back.
- Evaluate the fallback destination independently, including its own suppression check.
- Record the decision even when you don't send anything.
That last step matters more than people expect. "No attempt" can be a real, legitimate outcome. If an email fallback was blocked because the destination was suppressed, store that reason. Otherwise, later you'll struggle to tell the difference between an intentional block and a routing bug.
Distinguish permanent failure from temporary delay
Suppression policy depends on evidence quality. A permanent invalid-recipient event can justify creating or extending a suppression record. A timeout usually can't - it often signals provider latency, carrier conditions, a polling delay, or a transient network issue, not an invalid destination.
This is where overly broad rules harm users. If you suppress on every failure, temporary problems can lock out legitimate users. If you suppress only after repeated failures, you avoid contacting destinations that you already know are broken - but you still get useful retries for uncertain cases.
Define your event classes and actions deliberately:
| Normalized class | Typical handling | Why |
|---|---|---|
| Delivered | Keep evidence; do not infer code entry | Delivery is not authentication success |
| Temporary failure | Retry or wait according to policy | Destination may still be valid |
| Permanent invalid | Suppress the affected destination | Repeated sends are unlikely to help |
| Unknown | Preserve and alert; avoid automatic assumptions | A new provider status may change behavior |
Keep the provider's raw status available alongside the normalized class. If a provider adds a new status and you map it silently to "failed," you can trigger fallback decisions you didn't earn. Mapping it to "delivered" isn't better. "Unknown" should mean you have evidence you don't yet understand.
Suppression decisions also need context. Store the event that triggered the transition, the decision time, scope, and policy version. If a suppression expires or gets cleared, record that transition too. That's what makes the decision auditable instead of just a boolean stuck on some contact record.
Poll provider events with replayable evidence
Polling is a reasonable starting point for a beginner implementation. It avoids exposing an inbound endpoint for provider callbacks and gives your application control over when it fetches events. The trade-off is latency: a poller can't react faster than its polling interval and the provider's event availability.
Make the worker boring. Fetch events after the last committed cursor, normalize known event types, store the evidence, and advance the cursor. Persist the fetched events and the next cursor in one transaction. If the process stops, re-fetching the same page should be safe.
Make replay safe, not "exactly once"
Don't build your design around a promise of exactly-once delivery across your database and the external provider. That guarantee is hard across independent systems. Instead, make replay harmless with a uniqueness constraint on (source, provider_event_id) and update the cursor atomically.
A minimal, application-owned boundary can look like this:
type DeliveryEvent struct {
Source string
ProviderEventID string
AttemptID string
RawStatus string
ObservedAt time.Time
}
type EventPage struct {
Events []DeliveryEvent
NextCursor string
}
type EventSource interface {
Fetch(ctx context.Context, cursor string, limit int) (EventPage, error)
}
type EvidenceStore interface {
CommitPage(ctx context.Context, events []DeliveryEvent, nextCursor string) error
}
func PollOnce(ctx context.Context, src EventSource, store EvidenceStore, cursor string) error {
page, err := src.Fetch(ctx, cursor, 200)
if err != nil {
return fmt.Errorf("fetch delivery events: %w", err)
}
if err := store.CommitPage(ctx, page.Events, page.NextCursor); err != nil {
return fmt.Errorf("commit delivery evidence: %w", err)
}
return nil
}
The contract lives in CommitPage: insert events idempotently and advance the cursor in the same transaction. If the transaction fails, don't treat the event page or the cursor as committed. If the worker restarts after a success, replaying the page should not create duplicates or skip records.
Preserve the provider event ID, original status, and observed time. Normalize the status for policy decisions, but don't discard the source value. That keeps audits and future normalization updates possible without pretending your old interpretation was the only one.
Know when polling is no longer enough
Polling reduces inbound complexity for delayed evidence, and that's a good first trade-off. But it's not a good fit if your fallback decision has a hard latency target shorter than your polling interval and provider event availability. Measure event-ingestion lag and backlog age before changing the design.
If you need faster decisions, authenticated push events can provide a fast path. Keep cursor-based polling as reconciliation so missed or delayed callbacks can still be recovered. This hybrid approach is more complex to deploy, secure, and test. Add it because your service requirements demand it - not because "webhooks feel more real-time" sounds good on paper.
Alert on decisions, not just provider dashboards
A vendor dashboard can show provider-side activity. It usually won't tell you whether your application checked suppression before routing, which policy version allowed a fallback, or whether an event got processed twice. Alerts should focus on application decisions and invariants.
Start with two layers:
- Fast invariant alerts: a delivery attempt was admitted even though the recipient was already suppressed for that channel; a cursor that has stopped advancing; or a spike in unknown event classes.
- Rate-based alerts: rising fallback attempts per challenge, permanent-invalid outcomes, or event-ingestion lag - segmented by channel and region.
Posting a page for every failed OTP creates noise. Waiting until fallback volume doubles may come too late when the damage is already underway. Tune thresholds against normal variation in carrier and mailbox behavior. Record why you changed a threshold, then test with synthetic events instead of waiting for a real incident.
Good test cases include duplicate events, out-of-order arrival, cursor replay, an unknown status, a primary timeout, a suppressed fallback, and two workers processing overlapping pages. These tests exercise the decisions that make the system trustworthy. A green provider dashboard does not.
Region can be a useful low-cardinality field for comparing operational behavior in US vs EU traffic. Don't treat geography as a proxy for consent or legal basis - those are explicit policy inputs owned by legal and compliance. The system should record which policy was applied, not try to guess based on a phone prefix.
Message construction affects operations too. SMS segmentation depends on encoding: a single GSM-7 message commonly allows up to 160 characters, while UCS-2 commonly allows up to 70 (and lower limits apply for concatenated messages). A single unexpected character can change segmentation and cost. Keep your OTP SMS copy short, test the rendered encoding, and record a template version rather than logging the secret-bearing message body.
For email, set up sender authentication and domain hygiene before you lean on fallback. Google's email sender guidelines cover SPF and DKIM requirements, plus stronger requirements for bulk senders that include DMARC. These controls build a better foundation, but submitting successfully still doesn't prove inbox delivery. Use later bounce evidence through the same suppression pipeline.
A useful incident-review question is: Could the first permanent invalid-recipient event have prevented the next attempt, and does the evidence prove it? If the answer forces someone to reconstruct a mutable status field or open a provider console to piece together the story, your audit trail isn't complete.
Conclusion: keep the first version small and explainable
You don't need a messaging platform to build a dependable OTP fallback flow. Start with a challenge, channel-specific attempts, normalized append-only events, explicit suppression records, and a cursor that's committed atomically with each page. Add alerts for policy violations and ingestion health before you tune volume thresholds.
Your next step is to test failure paths - not to add another abstraction. Simulate a bounce, timeout, duplicate event, and suppressed fallback, then verify the system makes the expected decision and records why. That's the difference between a flow that merely sends messages and one your team can actually run. Use the checklist above to review your login path before the next change goes live.