DEV Community

HoldenFox8476
HoldenFox8476

Posted on

Should a SaaS Password Recovery Flow Use Email API or SMS OTP?

Short answer: use an emailed, single-use reset link as the default for most SaaS login recovery, and add SMS OTP only where users may genuinely lack email access or the product already maintains verified phone numbers. Email is usually the simpler system because the login identifier, recovery destination, and support workflow can remain in one channel. SMS can shorten the interaction, but it adds phone-number lifecycle, message segmentation, regional consent, and delivery-state work. “Cheaper” depends on your traffic and failure rates, so model completed recoveries rather than message sends.

This is a recovery decision, not a notification preference. The goal is to return the right person to an account without turning a delayed message, an expired credential, or a recycled phone number into an account takeover or a support queue. I've worked around enough spam filtering, rate limiting, and OTP delivery gaps to treat the channel as one component of that system — never as the system itself.

What should a SaaS password recovery flow use: email API or SMS OTP?

Start with the account data you can already trust. If every user signs in with an email address and changing that address is a controlled operation, an email reset link creates the smaller data surface. The service generates a high-entropy, single-use token, stores only a protected representation of it, sends a link, and accepts that token once before a short expiry. The browser then moves the user into a password-change session.

An SMS OTP flow looks compact on screen, yet the backend has more questions to answer. Was the phone number verified recently? Can the user update it without being signed in? How are country codes normalized? What happens when a number is reassigned? Does the support team have a safe path for a person who lost the device? A six-digit form doesn't make those policy decisions disappear.

So the default is straightforward.

Choose email first when email is the stable account identifier and recovery is occasional. Consider SMS as an additional path when the service has a legitimate reason to collect and continuously verify phone numbers, or when an email-only path would strand a meaningful user group. Don't add SMS merely because entering a short code feels faster in a demo.

The catch is that email is not suitable when users routinely lose access to the mailbox that identifies them. In that case, a separately verified recovery factor or a support-assisted process may be necessary. SMS is also not suitable when phone ownership is weak evidence in the product's environment, when regional messaging operations are not staffed, or when collecting phone numbers would create disproportionate privacy and compliance work. Neither channel is a universal fallback.

The delivery constraint comes before the screen

For email, delivery begins with domain alignment and message integrity. DKIM lets a signing domain take responsibility for a message and lets a receiver detect changes to signed content in transit. That is useful infrastructure, but a valid signature is not a promise that the message will reach the inbox or that the sender is trustworthy. Recovery design still needs stable sending identity, conservative content, bounce handling, suppression, and monitoring around the entire path.

Password-reset mail should also be boring. Keep the purpose obvious, avoid unnecessary tracking, don't include the current password, and make expiration clear without exposing account state. The request endpoint should give the same public response for an existing and a nonexistent account. Otherwise the recovery form becomes an email-address discovery tool.

SMS has a different physical constraint: encoding changes capacity. A single GSM-7 message can contain up to 160 characters, while UCS-2 reduces that to 70. Concatenated messages reserve characters for segmentation, leaving 153 GSM-7 or 67 UCS-2 characters per segment. A curly quote, non-Latin name, or translated sentence can therefore turn one planned message into multiple segments. That affects cost and makes truncation tests important.

Tiny changes matter.

Keep an OTP message short, put the code near the start, and don't ask the user to reply. Test the exact production template with every supported locale and with the encoding calculation used by the messaging path. Your mileage may vary by destination and carrier; delivery telemetry from the actual launch countries is what resolves that uncertainty, not an estimate made from an English template.

Channel delivery is asynchronous in both cases. The UI should never imply that “sent” means “received,” and a retry must not silently create several simultaneously valid credentials. This is where otherwise tidy implementations tend to fray: each click creates another token, delayed messages arrive out of order, and the user enters a code that was valid two requests ago.

Make recovery one state machine, not two endpoint piles

Model email links and texted codes as two presentations of the same recovery challenge. A challenge has a subject, a channel, a destination snapshot, a creation time, an expiry, an attempt count, a send count, and a terminal state. It should reveal as little as possible in logs. In particular, log a challenge identifier and transition reason, not the raw token or OTP.

The core transitions are small enough to review:

From Event To Required behavior
absent recovery requested pending Return a neutral response and apply abuse controls
pending delivery accepted active Start or retain the same challenge validity window
active valid proof submitted consumed Issue a narrow password-change session
active invalid proof submitted active or locked Count attempts without revealing account existence
active expiry reached expired Reject the proof and require a new challenge
any nonterminal state newer challenge wins superseded Prevent old and delayed messages from succeeding

That last row deserves attention. “Newest challenge wins” is easy for a user to understand, but it requires atomic invalidation. Another valid policy is to keep one challenge and resend the same proof within a bounded window. Pick one policy, document it, and test concurrency. Mixing the two creates hard-to-reproduce failures.

Here is a deliberately channel-neutral core. It omits storage and transport details so the security-sensitive decisions remain visible:

from dataclasses import dataclass
from datetime import datetime
from enum import Enum


class Status(str, Enum):
    ACTIVE = "active"
    CONSUMED = "consumed"
    EXPIRED = "expired"
    LOCKED = "locked"


@dataclass
class RecoveryChallenge:
    challenge_id: str
    proof_digest: bytes
    expires_at: datetime
    attempts: int
    max_attempts: int
    status: Status


def verify_challenge(
    challenge: RecoveryChallenge,
    candidate_digest: bytes,
    now: datetime,
) -> Status:
    if challenge.status is not Status.ACTIVE:
        return challenge.status
    if now >= challenge.expires_at:
        return Status.EXPIRED
    if challenge.attempts >= challenge.max_attempts:
        return Status.LOCKED
    if candidate_digest != challenge.proof_digest:
        challenge.attempts += 1
        return Status.LOCKED if challenge.attempts >= challenge.max_attempts else Status.ACTIVE
    return Status.CONSUMED
Enter fullscreen mode Exit fullscreen mode

In production, compare secret-derived values with a constant-time primitive, make the consume transition atomic, and bind the resulting session to password change only. A successful proof shouldn't mint a normal long-lived login session. Also decide what happens to existing sessions after the password changes; that is an account policy, not an email or SMS detail.

Abuse controls belong around challenge creation and verification. Rate-limit by several signals rather than one obvious address, because a single-key limit either blocks households and offices or remains easy to distribute around. The public response stays neutral while internal metrics distinguish suppressed, attempted, accepted, delivered, expired, locked, and consumed states. Those distinctions are how an operator finds a delivery gap without leaking it to an attacker.

Compare completed recovery cost, not message price

A per-message quote cannot answer which channel is cheaper. Use a simple cost model that includes sends, retries, engineering ownership, compliance work, support contacts, and abuse. Then divide by successful recoveries. A channel with a lower send cost can still be the expensive choice if delivery failures or confusing retries create more support work.

The comparison should be made per region and per user cohort:

Decision factor Email reset link SMS OTP
Existing account data Often already present for SaaS login Requires a collected and verified phone number
Message sizing Template size is rarely the billing unit Encoding and concatenation can change segment count
Recovery interaction Open link, then set password Copy or autofill code, then set password
Delivery operations Domain signing, bounce and suppression handling Country, carrier, encoding, consent and number lifecycle handling
Lost destination Mailbox recovery or support path Device loss, number change or reassignment path
Best fit Email-address accounts with infrequent recovery Products already operating verified mobile identity

Do not average the US and EU into one line on a spreadsheet. Languages change SMS encoding and template length. Consent, retention, sender identity, and support procedures also need review in each launch market. I'm not sure any generic traffic estimate can settle the decision for a particular SaaS product; a small, instrumented rollout with real destination mix and a written compliance review can.

There is a DX cost too. Two channels mean two template systems, two sets of delivery events, and twice the temptation to let behavior drift. Keep one challenge API behind them and make transport adapters translate provider-specific delivery states into a small internal vocabulary. That preserves the option to change transports without rewriting account recovery policy.

Roll out the least complex path first

Ship the state machine and email path behind a feature flag, with synthetic checks that request a recovery, retrieve the test message, consume the token, and confirm that reuse fails. Add dashboards for time to delivery, time to consume, resend rate, expiry rate, lock rate, and support contacts. Avoid publishing a single “delivery rate” that hides whether messages were merely accepted upstream.

Then test the ugly edges: concurrent requests, an expired link in an old tab, a password change while another recovery is pending, casing and Unicode in identifiers, delayed delivery, and a user who requests several messages quickly. Exercise deployment rollback with challenges created by both application versions. Recovery data outlives a request and can cross a release boundary.

Add SMS only after writing down the user cohort it rescues and the operational owner for it. Reuse the same challenge rules, validate each localized template's segment count, and stage availability by country. Compare completed-recovery and support outcomes against the email path; don't interpret raw send volume as success.

The simplest channel is the one your team can operate correctly on a bad day. For the usual email-identified SaaS account, that is an email reset link. SMS earns its place when it solves a documented access problem and the product is prepared to own phone-number and regional messaging constraints.

References

Top comments (0)