Short answer: build passwordless support-agent sign-in as one OTP challenge with two delivery attempts, then feed permanent email bounces into the same recipient-suppression boundary used by authentication and customer-support messaging.
The before picture is two routes that each create a code, send it, and guess whether the address is still usable. The after picture is one challenge, one verification path, and channel adapters that report delivery events into a recipient-status store. SMS goes first. Email is a fallback, not a second identity check.
Keep it boring.
That shape minimizes integration work because HTTP handlers don't know provider details, bounce consumers don't know login details, and a replacement delivery service only has to satisfy a small TypeScript interface. It also prevents an invalid support address from being retried by every subsystem that happens to have a send button.
What should an Express JS 2FA login do when SMS OTP needs email fallback?
Treat the login attempt as a state machine. created means the server has generated and stored a challenge. sms_sent and email_sent describe delivery attempts, not proof that a person received anything. Only successful verification creates an authenticated session. A permanent email bounce changes recipient eligibility for later sends; it must never mark an OTP as verified.
In words, the diagram is: request challenge -> normalize identity -> check suppression -> create one hashed OTP -> try SMS -> offer email fallback -> verify the same challenge -> consume it exactly once. Separately: delivery event -> authenticate event -> classify permanent bounce -> suppress normalized email. The two flows meet at recipient status, nowhere else.
Don't let the browser choose arbitrary destinations. It should submit an account identifier, while the server loads the phone number and email already bound to that support agent. Otherwise an attacker can redirect the fallback before proving control of the account.
SMS encoding belongs at the adapter boundary too. SMS messages encoded with GSM-7 have different single-message and multipart limits from UCS-2 messages, so a template edit can change segmentation. Keep the OTP copy short, measure the encoded result in the adapter, and emit segment count as a metric rather than assuming character count tells the whole story.
Build one challenge and two channel adapters
Start with contracts. The example leaves provider SDKs outside the application core and deliberately uses opaque delivery IDs. It also makes policy choices visible: this sample uses a six-digit code, a five-minute lifetime, and five verification attempts. Those are example settings, not universal security constants; tune them against your own threat model and support burden.
import { createHash, randomInt, randomUUID } from "node:crypto";
type Channel = "sms" | "email";
type ChallengeState = "created" | "sms_sent" | "email_sent" | "consumed";
type Challenge = {
id: string;
agentId: string;
codeHash: string;
expiresAt: number;
attemptsLeft: number;
state: ChallengeState;
};
type Agent = {
id: string;
phone: string;
email: string;
};
interface ChallengeStore {
put(challenge: Challenge): Promise<void>;
get(id: string): Promise<Challenge | null>;
update(challenge: Challenge): Promise<void>;
}
interface RecipientStatus {
isSuppressed(channel: Channel, address: string): Promise<boolean>;
suppress(channel: Channel, address: string, reason: string): Promise<void>;
}
interface OtpSender {
send(address: string, code: string): Promise<{ deliveryId: string }>;
}
const normalizeEmail = (value: string) => value.trim().toLowerCase();
const hashCode = (challengeId: string, code: string, secret: string) =>
createHash("sha256").update(`${challengeId}:${code}:${secret}`).digest("hex");
The long paragraph here matters: never persist the plaintext code in logs, traces, analytics, or the challenge record. Store a keyed or secret-bound digest, redact destination fields in telemetry, and make the challenge ID the correlation handle. Also decide which system owns normalization. Lowercasing and trimming an email is shown because the suppression key and lookup key must agree, but mailbox semantics can vary; your mileage may vary if your identity directory already owns canonical addresses. Pick one owner and test it. A mismatch creates the ugliest kind of observability failure — the bounce dashboard says “suppressed” while the authentication path looks up a different string and keeps sending.
Now wire a single service to the adapters. No delivery provider appears in the route.
const OTP_TTL_MS = 5 * 60 * 1000;
const MAX_ATTEMPTS = 5;
class LoginService {
constructor(
private readonly challenges: ChallengeStore,
private readonly recipients: RecipientStatus,
private readonly sms: OtpSender,
private readonly email: OtpSender,
private readonly hashSecret: string,
) {}
async begin(agent: Agent): Promise<{ challengeId: string; channel: Channel }> {
const id = randomUUID();
const code = randomInt(100_000, 1_000_000).toString();
const challenge: Challenge = {
id,
agentId: agent.id,
codeHash: hashCode(id, code, this.hashSecret),
expiresAt: Date.now() + OTP_TTL_MS,
attemptsLeft: MAX_ATTEMPTS,
state: "created",
};
await this.challenges.put(challenge);
if (!await this.recipients.isSuppressed("sms", agent.phone)) {
await this.sms.send(agent.phone, code);
challenge.state = "sms_sent";
await this.challenges.update(challenge);
return { challengeId: id, channel: "sms" };
}
return this.sendEmailFallback(challenge, agent.email, code);
}
async sendEmailFallback(
challenge: Challenge,
email: string,
code: string,
): Promise<{ challengeId: string; channel: Channel }> {
const address = normalizeEmail(email);
if (await this.recipients.isSuppressed("email", address)) {
throw new Error("No eligible delivery channel");
}
await this.email.send(address, code);
challenge.state = "email_sent";
await this.challenges.update(challenge);
return { challengeId: challenge.id, channel: "email" };
}
}
There is a deliberate gap: after storing only a digest, sendEmailFallback cannot recover the plaintext code later. Don't “solve” that by writing the code to the database. Either offer fallback during the initial request, keep the secret in a short-lived encrypted store, or generate a new code and atomically replace the digest for the same challenge. The product decision changes the code. The invariant does not: exactly one digest is valid at a time.
Connect bounce suppression without coupling it to login
Email delivery systems report events asynchronously, and the exact event envelope depends on the delivery integration. Convert that external envelope into a small internal event at the edge, after authenticating it according to the sender's documented mechanism.
type DeliveryEvent = {
deliveryId: string;
channel: "email";
address: string;
outcome: "delivered" | "temporary_bounce" | "permanent_bounce";
};
async function applyDeliveryEvent(
event: DeliveryEvent,
recipients: RecipientStatus,
): Promise<void> {
if (event.outcome !== "permanent_bounce") return;
await recipients.suppress(
"email",
normalizeEmail(event.address),
"permanent_bounce",
);
}
Make this consumer idempotent by storing the delivery ID before applying a repeated event. Keep temporary and permanent outcomes distinct. A temporary bounce is evidence for retry policy and alerting; a permanent bounce is evidence that this address should be suppressed until a controlled account-recovery flow replaces or revalidates it. Don't silently unsuppress it when an agent retries login.
This boundary also protects ordinary customer-support mail. The authentication service and the ticket-notification service consult the same suppression decision, but neither needs to parse a provider webhook. One small contract. Big reduction in accidental retries.
Observe the transitions rather than the message body
The useful log is a transition: challenge.created, delivery.attempted, fallback.selected, challenge.verified, challenge.expired, or recipient.suppressed. Attach the challenge ID, hashed agent identifier, channel, template version, outcome, latency, and delivery ID where available. Do not attach the OTP or raw email and phone values.
Metrics should answer operational questions directly. Track challenge starts, send attempts by channel, fallback selections, verification outcomes, expirations, permanent bounces, and SMS segment counts. Then alert on ratios over a meaningful traffic window, not on a single failed delivery. I'm not sure which channel will reach a particular support team first; production delivery traces, split by destination region and carrier or mailbox domain where policy permits, are what resolve that uncertainty.
Test the state transitions before testing provider wiring. A compact suite should prove that a suppressed SMS destination selects eligible email, a permanently bounced email blocks fallback, an expired challenge cannot verify, the attempt counter reaches zero, a consumed challenge cannot be reused, and duplicate delivery events do not create duplicate suppression work. Then run adapter contract tests against test facilities supplied by whichever services you select.
The limitation is direct: SMS-first passwordless access is not suitable for agents who cannot reliably possess a phone, for policies that require phishing-resistant authentication, or for shared support accounts that prevent attribution. Choose an authenticator or hardware-backed sign-in design in those cases. Email fallback is also a poor recovery channel if the mailbox and support account share the same compromised session. This architecture optimizes integration effort and delivery resilience; it does not turn two delivery channels into two independent authentication factors.
Should the API wait for SMS failure before offering email?
Usually, no. A synchronous request can confirm that a sender accepted work, but it cannot prove human receipt. Waiting in the request also binds login latency to an external delivery timeline. Return the challenge state, let the client request fallback under a rate-limited policy, and let asynchronous events update observability and suppression.
The alternative is automatic fallback after a timer. It reduces user action but can send two live copies of one credential and makes late SMS delivery confusing. Use it only when your risk review accepts that behavior and your challenge design guarantees one valid code. For many support teams, an explicit “send by email” action is easier to explain and audit.
| Fallback policy | Integration effort | Best fit | Main limitation |
|---|---|---|---|
| User-requested email | One additional action and one guarded endpoint | Teams that favor visible, auditable transitions | Adds friction when SMS is slow |
| Timed automatic email | Scheduler plus race-safe challenge updates | Flows where reducing user action outweighs duplicate delivery | May deliver the credential twice |
| No email fallback | Smallest channel surface | Environments with a separate recovery method | SMS disruption blocks this login path |
Either way, protect both endpoints with per-account and per-origin throttles, return responses that do not reveal whether an account exists, and serialize challenge updates so two fallback clicks cannot race. Fast is good. Predictable is better.
References
Further reading
The two primary references above cover the email-service model and the SMS encoding constraint that most directly affect this implementation.
Top comments (0)