A customer-support SaaS can't leave an agent staring at a spinner during a two-factor login, but it also shouldn't fire an email code merely because an SMS receipt is a few seconds late. The integration has to make one decision, once, while keeping bounced backup addresses out of later attempts.
Short answer: model SMS delivery and backup email as states of one OTP challenge, poll the SMS receipt for a short bounded window, and use an atomic state transition to issue a fresh email code only after SMS fails or that window expires.
The important part isn't the second send call. It's ownership of the transition. Without that, two workers can send two valid codes, an old browser tab can verify the wrong challenge, and a known-invalid email address can remain in the fallback path. For a solo SaaS, this is exactly the kind of undifferentiated edge case I want contained behind a small interface so weekly shipping doesn't turn into weekly authentication archaeology.
The integration budget starts with bounced addresses
Treat the login as one challenge with a monotonically increasing version. The active channel, code hash, expiry, delivery reference, and attempt count belong in the same record. Sending email is a state transition from sms_pending to email_pending; it isn't an independent side effect that a controller may retry whenever it gets nervous.
There are four outcomes worth distinguishing. A delivered SMS keeps the challenge on SMS. An explicit failed receipt permits fallback. An unresolved receipt permits fallback only after the configured deadline. A successful verification closes the challenge and makes every later poll a no-op. That last rule matters because receipt polling and a user's code submission can happen at nearly the same instant.
Don't infer delivery from the initial send response. In this design, that response means only that the messaging adapter accepted the request and returned a receipt ID. The poller asks about that ID later. This separation also makes the integration testable: the authentication service depends on a tiny contract rather than on one provider's response shape.
Keep the waiting period product-specific. I'm not sure there is a universal timeout that balances impatient users, carrier latency, and the risk of duplicate messages; production receipt latency and login-abandonment data should settle it. What is universal is the bound. Poll forever and the user never reaches the backup path.
The customer-support scenario adds a less obvious requirement: backup email can itself become undeliverable. A hard-bounce event should suppress that address before another login attempts to use it. Suppression is durable account data, while a transient delivery state belongs to one challenge. Mixing them creates bad recovery behavior, such as permanently suppressing an address because one message was merely delayed.
I use an adapter boundary with three operations: send a text, read its receipt, and send an email. A separate event consumer records email bounces. The core authentication code doesn't know vendor route names, SDK types, or webhook envelopes. That is a revenue-per-hour decision as much as an architecture decision — changing a messaging integration should consume an afternoon, not a release cycle.
DKIM belongs on the email delivery side, not in the OTP state machine. RFC 6376 defines a domain-level signature that a verifier can validate, but a valid signature is not proof that a recipient mailbox accepted a message. Keep authentication of the message, delivery events, and application-level code verification as separate signals.
And keep the response boring. Login endpoints should return the same public shape whether the backup address is present, absent, or suppressed, so this recovery feature doesn't become an account-discovery oracle.
| Current state | New evidence | Atomic action | Result |
|---|---|---|---|
sms_pending |
Delivered receipt | Mark delivered | Keep SMS code active |
sms_pending |
Failed receipt or deadline | Rotate code and enqueue email | Activate email code |
| Any open state | Correct active code | Mark verified | Reject later transitions |
| Any open state | Expired challenge | Mark expired | Offer another recovery path |
Build the state transition before the transport adapters
The following example leaves transport details behind interfaces and concentrates the race-sensitive logic in the store. moveToEmail must be a compare-and-set operation in a real database: it succeeds only when the expected version and state still match. The winning worker rotates the code, invalidating the SMS code as the email handoff begins.
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
type ReceiptState = "queued" | "delivered" | "failed" | "unknown";
type ChallengeState =
| "sms_pending"
| "sms_delivered"
| "email_pending"
| "verified"
| "expired";
type Challenge = {
id: string;
userId: string;
phone: string;
backupEmail: string | null;
codeHash: string;
receiptId: string;
state: ChallengeState;
fallbackAt: number;
expiresAt: number;
version: number;
};
interface Messages {
sendSms(to: string, body: string): Promise<{ receiptId: string }>;
receipt(id: string): Promise<ReceiptState>;
sendEmail(to: string, subject: string, body: string): Promise<void>;
}
interface Challenges {
get(id: string): Promise<Challenge | null>;
markSmsDelivered(id: string, version: number): Promise<boolean>;
moveToEmail(
id: string,
version: number,
nextCodeHash: string,
): Promise<boolean>;
markVerified(id: string, version: number): Promise<boolean>;
}
interface SuppressionList {
has(address: string): Promise<boolean>;
add(address: string, reason: "hard_bounce"): Promise<void>;
}
const hashCode = (challengeId: string, code: string): string =>
createHash("sha256").update(`${challengeId}:${code}`).digest("hex");
const newCode = (): string => randomInt(0, 1_000_000).toString().padStart(6, "0");
export async function pollSmsAndMaybeFallback(
challengeId: string,
now: number,
messages: Messages,
challenges: Challenges,
suppressions: SuppressionList,
): Promise<"waiting" | "delivered" | "email_sent" | "closed"> {
const challenge = await challenges.get(challengeId);
if (!challenge || challenge.state !== "sms_pending") return "closed";
if (now >= challenge.expiresAt) return "closed";
const receipt = await messages.receipt(challenge.receiptId);
if (receipt === "delivered") {
await challenges.markSmsDelivered(challenge.id, challenge.version);
return "delivered";
}
const fallbackDue = receipt === "failed" || now >= challenge.fallbackAt;
if (!fallbackDue) return "waiting";
if (!challenge.backupEmail) return "closed";
if (await suppressions.has(challenge.backupEmail)) return "closed";
const emailCode = newCode();
const moved = await challenges.moveToEmail(
challenge.id,
challenge.version,
hashCode(challenge.id, emailCode),
);
if (!moved) return "closed";
await messages.sendEmail(
challenge.backupEmail,
"Your login code",
`Your login code is ${emailCode}`,
);
return "email_sent";
}
export async function verifyCode(
challengeId: string,
candidate: string,
now: number,
challenges: Challenges,
): Promise<boolean> {
const challenge = await challenges.get(challengeId);
if (!challenge || now >= challenge.expiresAt) return false;
if (!["sms_pending", "sms_delivered", "email_pending"].includes(challenge.state)) {
return false;
}
const expected = Buffer.from(challenge.codeHash, "hex");
const actual = Buffer.from(hashCode(challenge.id, candidate), "hex");
if (!timingSafeEqual(expected, actual)) return false;
return challenges.markVerified(challenge.id, challenge.version);
}
export async function recordHardBounce(
address: string,
suppressions: SuppressionList,
): Promise<void> {
await suppressions.add(address.trim().toLowerCase(), "hard_bounce");
}
How should two-factor authentication fallback when SMS delivery polling fails?
Consider the concrete race rather than the happy-path diagram. A poll worker reads version 4 in sms_pending and sees a failed receipt. Before it writes, a second worker reads the same version because a delayed queue job was delivered twice. Both create a candidate email code, but only one compare-and-set can change version 4 to version 5; the loser exits without sending. Now put the user's browser into the same sequence: if the browser verifies the SMS code before either worker commits, verification advances version 4 and both fallback writes lose; if fallback commits first, it rotates the hash and the old SMS code no longer verifies. This is why a Boolean such as fallbackSent is too weak. The version orders events that arrived concurrently, while the state says which operations remain legal. One awkward boundary still remains: the database transition can commit and the email send can then fail at the transport boundary. Don't solve that by moving the send before the commit, because that reopens the duplicate-send race. In production, write an email job to an outbox in the same transaction as moveToEmail, then let a worker deliver that job with an idempotency key derived from the challenge version. The example keeps the outbox out of view because storage implementations differ, but the transaction boundary is part of the design, not optional polish.
One winner. One code.
The code also normalizes an address when recording a bounce. Use the same normalization policy when checking suppression, or store a canonical recipient key at account creation. Otherwise Agent@Example.com can be suppressed while agent@example.com slips through the fallback check.
Observability earns its place before scale
First, replace per-login timers with a delayed queue and partition work by challenge ID. Polling should use bounded attempts with jitter, and the queue should carry only an ID; the database remains the source of truth. A worker that wakes late reads current state and exits if another path already verified or advanced the challenge. Second, instrument state transitions rather than dumping addresses, phone numbers, or codes into logs. Useful counters include receipt outcomes, fallback eligibility, suppressed fallbacks, verification channel, and time from challenge creation to verification. Break them down cautiously because authentication telemetry can become sensitive data very quickly. Third, test races deliberately: run two fallback workers against the same version and assert that exactly one wins; verify an SMS code while a worker is preparing fallback and assert that the final state has one winner; feed the same hard-bounce event twice and assert idempotent suppression; then advance a fake clock just before and just after fallbackAt. These tests buy more confidence than a large mock of a vendor SDK. At higher volume, I'd also separate policy from mechanics. A policy function can decide whether email is eligible based on account configuration, suppression, challenge age, and risk signals; the state machine then executes that decision. This keeps a future change — for example, disabling fallback for privileged support roles — from leaking into receipt adapters and queue workers.
Ship the small state machine first.
Governance boundaries for recovery channels
Email fallback is not suitable when the mailbox is the same recovery surface an attacker can already reset, when policy requires phishing-resistant authentication, or when an account has no previously verified backup address. In those cases, stick with pre-enrolled recovery codes or a stronger authenticator appropriate to the account's risk. SMS-to-email fallback improves availability; it doesn't upgrade the assurance of either channel.
Polling is also the wrong fit when the transport can deliver trustworthy events into an existing event pipeline with less integration work. Events avoid repeated status reads. Polling wins for a small service when the adapter exposes receipt state, the traffic is modest, and owning another public callback plus signature validation would cost more operational time than a bounded worker. Your mileage may vary because the crossover depends on login volume and the event infrastructure you already run.
The practical decision rule is narrow: add backup email only after the address has been verified, suppression is checked, the SMS outcome has reached an explicit failure or bounded deadline, and one atomic transition controls the handoff. If any condition is missing, keep the challenge closed and offer a different recovery path. That produces less clever code, fewer ambiguous states, and a login flow a one-person team can actually operate while continuing to ship weekly.
References
- RFC 6376, DomainKeys Identified Mail (DKIM): https://datatracker.ietf.org/doc/html/rfc6376
- Anthropic, Tool use overview: https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview













