Short answer: For a US/EU SaaS login, make email OTP and SMS OTP separate, policy-controlled channels, with a stronger factor available for account recovery. Pick the default channel from the user population and threat model, then make fallback obey the same attempt and rate limits.
For a logistics product, that policy matters when a dispatcher signs in to route a contact form to the right support queue. The form workflow can wait for a queue decision; the login flow cannot safely wait forever for a message. The application should create one login challenge, request one channel, record its state on the server, and allow a deliberate fallback without creating a second unaudited login attempt.
How should a SaaS login compare SMS OTP and email OTP for US/EU deliverability, security, fallback, cost, and rate limiting?
Start with the distinction between delivery and authentication. SMS OTP depends on a phone number, mobile network, country policy, and carrier filtering. Email OTP depends on a reachable mailbox, sender authentication, mailbox filtering, and the user's access to that inbox. Neither channel proves that the browser belongs to the account owner; each proves possession of a destination at that moment.
Email delivery needs domain authentication and monitoring. DMARC is a useful standard for publishing a receiving policy and reporting relationship, but a passing email authentication check does not guarantee inbox placement. Open events are also weak evidence. Apple Mail Privacy Protection can fetch mail content in ways that make an open-derived signal unsuitable for deciding whether a login code was seen.
SMS has a different failure profile. A number may be recycled, roaming may be involved, and a message can arrive late. Treat a delivered-looking status as delivery telemetry, not as proof of identity. For high-risk actions, keep a phishing-resistant factor or an existing trusted session in the policy rather than treating either OTP channel as a universal answer.
Cost belongs in the abuse model. A public “send again” button can turn an attacker-controlled phone number or mailbox into a message bill. Rate-limit the login attempt, account, destination, IP or device signals, and fallback transitions. Use a product budget as well as any upstream limit.
Measure it by region.
The implementation walkthrough
The smallest safe flow has one server-side record per login attempt. It contains the user reference, destination hash, selected channel, creation time, expiry, number of verification attempts, and a consumed flag. A retry can request another delivery within policy; it must not reset the verification counter or extend the original trust window without an explicit policy decision.
The example below models that shared state. It does not send a message and it does not decide whether a submitted code is correct. Those responsibilities stay behind channel adapters, while the policy layer prevents an email fallback from bypassing SMS limits.
type Channel = "sms" | "email";
type OtpPolicy = {
ttlMs: number;
maxChecks: number;
maxSends: number;
};
type LoginChallenge = {
channel: Channel;
createdAt: number;
checks: number;
sends: number;
consumed: boolean;
};
class LoginOtpPolicy {
private readonly challenges = new Map<string, LoginChallenge>();
constructor(private readonly policy: OtpPolicy) {}
create(attemptId: string, channel: Channel, now: number): LoginChallenge {
const existing = this.challenges.get(attemptId);
if (existing && !this.expired(existing, now) && !existing.consumed) {
throw new Error("An active challenge already exists");
}
const challenge: LoginChallenge = {
channel,
createdAt: now,
checks: 0,
sends: 1,
consumed: false,
};
this.challenges.set(attemptId, challenge);
return challenge;
}
fallback(attemptId: string, channel: Channel, now: number): LoginChallenge {
const challenge = this.active(attemptId, now);
if (challenge.sends >= this.policy.maxSends) {
throw new Error("The send limit has been reached");
}
challenge.channel = channel;
challenge.sends += 1;
return challenge;
}
check(attemptId: string, codeIsValid: boolean, now: number): boolean {
const challenge = this.active(attemptId, now);
if (challenge.checks >= this.policy.maxChecks) return false;
challenge.checks += 1;
if (!codeIsValid) return false;
challenge.consumed = true;
return true;
}
private active(attemptId: string, now: number): LoginChallenge {
const challenge = this.challenges.get(attemptId);
if (!challenge || challenge.consumed || this.expired(challenge, now)) {
throw new Error("No active challenge");
}
return challenge;
}
private expired(challenge: LoginChallenge, now: number): boolean {
return now - challenge.createdAt >= this.policy.ttlMs;
}
}
const policy = new LoginOtpPolicy({ ttlMs: 300_000, maxChecks: 5, maxSends: 3 });
policy.create("login-42", "email", Date.now());
The in-memory map is only a compact model. A deployed service needs an atomic shared store, because two requests from different application instances must not both consume the final attempt. Code values should be protected at rest, comparisons should avoid leaking useful timing information, and successful verification should invalidate the challenge immediately. The code should never be accepted as a query parameter that a reverse proxy or analytics tool might retain.
The channel adapter comes after this boundary. It asks an email or SMS transport to deliver a challenge, then reports only the result needed by the policy layer. Keep provider-specific request formats out of the login controller. That makes a transport change a bounded integration task instead of a rewrite of account security logic.
Failure modes worth testing before launch
Test late delivery, duplicate submissions, refreshes, two browser tabs, and a fallback clicked while the first message is still in transit. Test a user who changes channel after two wrong codes. The expected result is one shared counter, one expiry, and no way to revive a consumed challenge.
Test the operational edges too. A 429 response should cause bounded backoff and should honor Retry-After when supplied; it should not trigger a tight retry loop. A mail open should not complete authentication. A successful login should make an old code unusable. A country not in the product's allowed SMS policy should receive a neutral user-facing response, while the detailed reason stays in protected logs.
Keep the state boring.
For the logistics contact form, exercise the complete path after login: the authenticated request should carry a server-issued identity, and queue routing should validate the form's category and region independently. Authentication is not authorization to every support queue. That separation is easy to lose when the OTP implementation is placed inside the form handler.
Choosing a default without pretending it is universal
Email is often the lower-friction default for a SaaS whose users already work from a managed mailbox. SMS may be the better default when the workflow is mobile-first and phone reachability is a known account attribute. The decision should come from observed completion, abuse, and support rates segmented by region, not from a single global deliverability score.
Here is a compact decision rule:
| Condition | Sensible starting point | Recheck before launch |
|---|---|---|
| Users live in a managed work inbox | Email OTP | Sender authentication and mailbox filtering |
| The job is mobile-first and phone ownership is established | SMS OTP | Country policy and carrier delivery |
| The account protects high-value data | Stronger factor plus recovery | Phishing resistance and assisted recovery |
| A channel is delayed or unavailable | Explicit, shared-limit fallback | No attempt-counter reset |
I'm not sure any universal US/EU default can survive different user populations. Your mileage may vary, and the missing evidence is easy to name: measure completion, abuse, and recovery outcomes by region before locking the policy.
The catch is that neither is suitable as the only recovery method for a high-value account. Stick with email when phone-number collection creates unnecessary privacy or compliance work. Stick with SMS when the user is rarely in the product's email environment and mobile reachability is central to the job. Choose a stronger factor or assisted recovery when account takeover would expose sensitive customer or shipment data.
I don't treat a fallback as a convenience button. In a busy dispatch operation, one person may open the login page on a laptop, request email, switch to SMS on a phone, refresh the laptop, and submit the first code that arrives. Each event can race the others. The server therefore needs one authoritative attempt record, an atomic consume operation, and a stable expiry. If the email and SMS handlers each keep their own counters, the user can get two fresh budgets by switching channels; if the browser keeps the state, a second tab can rewrite it. Those are ordinary concurrency cases, not exotic attacks. I've seen this class of design mistake described as “just delivery,” but it is an authentication state machine, and the distinction belongs in the code review checklist.
Keep the limits visible in product policy, but do not publish thresholds that would help attackers tune requests. Record challenge creation, delivery result, verification result, fallback, expiry, and denial with a correlation ID. Review those records by country and tenant. If the numbers change, revise the policy deliberately and test the migration; changing a limit silently during an active challenge is a confusing way to create support tickets.
Top comments (0)