+Short answer: use email verification codes as the default for B2B SaaS login 2FA, then offer SMS OTP as a recovery or risk-based fallback when a verified phone number is available. This usually gives the cleanest US/EU rollout because email bounces are observable and suppressible, while SMS reaches a phone without depending on an inbox. Neither channel is a complete answer to phishing or account takeover.
I care about the page that fires at 03:00, after a customer says the code never arrived. The useful decision is therefore not "which channel is cheapest?" It is: which failure can your team detect, suppress, retry, and explain without locking out a legitimate user?
What makes email codes and SMS OTP fail differently?
Email has a visible feedback loop. A provider can return a hard bounce, a mailbox can reject a message, and your system can suppress that address before the next login attempt. That loop only helps if your sending domain has SPF, DKIM, and DMARC aligned, and if you process bounce events instead of treating every accepted handoff as delivery. Google's sender guidance also expects authenticated mail and sensible unsubscribe handling for subscription traffic, details that affect reputation even when a message is transactional.
SMS is immediate when the handset and carrier cooperate, but the path is opaque. A phone can be roaming, filtered, out of coverage, or attached to a recycled number. Message length matters too: GSM-7 and UCS-2 use different segment limits, so a long localized template can become multiple billable segments and arrive out of order. Keep the code template short and ASCII where possible.
Measure first.
In one review, a team blamed its SMS provider for a slow login flow because the send endpoint returned quickly while users waited several minutes. The traces showed a different story: the API accepted the message, the carrier callback arrived late, and the resend button issued a second code that invalidated the first. That sequence is easy to reproduce in a test harness, yet it is often missed when dashboards count only HTTP 200 responses. Record the challenge ID across the request, provider callback, and verification attempt; then you can distinguish a carrier delay from a mailbox rejection, a stale browser tab, or a user who typed an older code. This evidence also makes a channel switch reversible: change the policy for new challenges, retain valid existing challenges, and compare cohorts instead of arguing from anecdotes.
One operational rule saves pain: never let a delivery timeout consume the only valid factor. A code should have a short lifetime, one-use semantics, an attempt counter, and a server-side record of the channel and destination class (domain, carrier region, or masked address—not the raw value in logs).
How should a US/EU SaaS choose login 2FA for deliverability and security?
Start with the account's risk and contact quality. Email is easier to deploy when every tenant already has a work address and your product owns the domain reputation. SMS is useful when the user cannot reach that mailbox, but it adds phone-number collection, regional sender registration, carrier filtering, and SIM-swap exposure. For an administrator changing billing or exporting data, require a stronger factor such as a passkey or authenticator app; treat email and SMS as recovery-friendly channels, not identical security levels.
The cheapest path is often the one that avoids support work. Email has infrastructure costs in sending, feedback processing, and reputation management. SMS has per-message and per-segment charges plus country-specific controls. Compare total incident cost: a blocked corporate domain, a duplicated SMS, or a support-driven reset can outweigh a small delivery price difference. I'm not sure any universal price ranking survives changes in carrier policy, volume, and template language, so measure your own cohorts.
A small, auditable verifier in Go
The channel adapter should be boring. Generate the code with a cryptographically secure source, store only a digest, and make verification atomic so two parallel requests cannot both win. The sender is injected; that keeps tests independent of an email or SMS vendor.
package otp
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
type Challenge struct {
Digest string
ExpiresAt time.Time
Attempts int
}
func NewCode() (string, string, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", err
}
n := (uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % 1000000
code := fmt.Sprintf("%06d", n)
sum := sha256.Sum256([]byte(code))
return code, hex.EncodeToString(sum[:]), nil
}
func Valid(c Challenge, digest string, now time.Time) bool {
return now.Before(c.ExpiresAt) && c.Attempts < 5 && digest == c.Digest
}
In production, persist the challenge with a compare-and-swap or transaction, increment attempts on every failed check, and invalidate it after success. Rate-limit requests per account, destination, IP, and tenant. Keep resend behavior explicit: a resend replaces the prior challenge, and the UI should say which masked channel was used.
What should the runbook measure before changing channels?
Instrument events, not just HTTP responses: challenge_created, send_accepted, delivered when the channel reports it, bounced or undeliverable, verified, expired, and locked_out. Break them down by country, carrier or mailbox domain, tenant, locale, and first-time versus returning device. Alert on a change in verified-after-send rate and on hard-bounce growth; those are earlier signals than a ticket queue.
Run a canary with a fixed template and a fixed expiry. Compare p50 and p95 time-to-code, resend rate, verification success, and support contacts for the same risk cohort. Test duplicate sends, delayed callbacks, clock skew, concurrent verification, and a user who requests email then SMS. The rollback is a policy switch: stop offering the degraded channel, preserve existing valid challenges, and leave an audit note explaining why.
The catch is that email-only is not suitable when users routinely lose mailbox access, and SMS-only is a poor fit for high-value actions or populations exposed to SIM swaps. Stick with email as the baseline when bounce suppression and domain authentication are strong; add SMS only where measured recovery value justifies its operational and security cost.
Top comments (0)