DEV Community

VelvetDusk629047
VelvetDusk629047

Posted on

5 Observable SMS and Email OTP Practices (For Reliable SaaS Login)

Choose SMS OTP or email OTP for SaaS login only after instrumenting the signup journey. For a developer tool sending a verification link, delivery reliability is the deciding constraint, and neither an accepted email nor an accepted text proves that a person can finish signup.

TL;DR: Use one channel as the normal path, keep fallback explicit, and measure five separate signals: request, provider acceptance, delivery evidence, link redemption, and completion. Compare SMS and email with those signals by region and failure class. Protect every channel with shared rate limits so fallback cannot multiply abuse.

The useful before-and-after mental model is small. Before: send -> accepted -> success. After: request -> accepted -> delivery evidence -> redeemed -> account verified, with an outcome and latency attached to each transition. This shift matters more than a generic channel ranking because it shows where reliability actually disappears.

Should SaaS login use SMS OTP or email OTP?

Acceptance belongs to the transport boundary. Signup completion belongs to the application boundary. Between them sit filtering, delayed delivery, an unavailable device or inbox, an expired link, and a user who abandons the flow. Treating those states as one sent boolean erases the information needed to choose a primary channel.

Email adds another measurement trap. Apple documents that Mail Privacy Protection can prevent senders from learning Mail activity and can download remote content in the background. An image-load event therefore should not be promoted to proof that the recipient read the verification message. For this workflow, the signed link redemption is the stronger application event.

Email authentication also has an operational surface. DMARC defines domain-level policy and reporting built on SPF and DKIM results. Those reports can reveal authentication alignment problems, but they do not replace redemption and completion telemetry. Different evidence. Different question.

Here is the diagram in words: the browser asks the identity service; the identity service creates one verification challenge; a channel adapter submits a message; asynchronous evidence updates the attempt; the browser redeems the same challenge; the account becomes verified. SMS and email are replaceable edges around one state machine, not two independent login systems. The practical trade-off is visibility against complexity: more transitions reveal exactly where a signup stalls, while every new transition also needs a schema, retention policy, dashboard, and owner. This model is not suitable for a team that cannot operate asynchronous delivery evidence yet. Start with request, redemption, completion, and failure; add transport-specific evidence when somebody will act on it.

No signal earns its keep merely by existing.

A compact event contract keeps the channel comparison honest. It also avoids putting an address, phone number, raw token, or full verification URL in logs. Those values are credentials or identifiers in practice, so the example records opaque IDs and coarse dimensions instead.

type Channel = "email" | "sms";
type Stage =
  | "requested"
  | "accepted"
  | "delivery_evidence"
  | "redeemed"
  | "completed"
  | "failed";

type VerificationEvent = {
  attemptId: string;
  challengeId: string;
  channel: Channel;
  stage: Stage;
  region: "US" | "EU";
  occurredAt: string;
  elapsedMs: number;
  failureClass?:
    | "rejected"
    | "delayed"
    | "expired"
    | "rate_limited"
    | "abandoned";
};

function recordVerification(event: VerificationEvent): void {
  telemetry.emit("verification.stage", event);
}
Enter fullscreen mode Exit fullscreen mode

The five positive stages are intentionally separate. accepted answers whether the downstream transport took responsibility. delivery_evidence holds channel-specific evidence without pretending the evidence is equivalent across channels. redeemed says the application received a valid link or code. completed says the account transition happened.

Keep failure classes bounded. Never attach arbitrary provider responses as metric labels; store diagnostic detail in access-controlled logs and put a stable classification on metrics. That gives an on-call engineer useful slices without creating an uncontrolled set of time series.

I first reach for a funnel, not a single delivery percentage. Split it by channel and US/EU region, then chart latency distributions from request to acceptance and request to redemption. The sharp comparison is the gap between adjacent stages. A widening accepted-to-redeemed gap points the investigation somewhere very different from a request-to-accepted gap.

Then alert on user impact. A short transport disturbance that does not change redemptions is less urgent than a quiet rise in expirations or abandoned attempts. The page should identify the broken transition, affected region, and channel. Fast diagnosis beats a vague "OTP failures are high" alarm.

Observe the delivery evidence chain

Do not compress security, reliability, and cost into one score. They create different decisions. Use the same review table for each region, and fill it with your own observed funnel rather than inherited assumptions.

Decision surface Email link SMS code or link Operational test
User endpoint Controlled through an inbox Controlled through a phone number and device Measure redemption, not submission alone
Delivery evidence Authentication reports and message events are separate from user action Transport events are separate from user action Keep evidence type on every event
Fallback Can recover a phone-path failure Can recover an inbox-path failure Require explicit user action and one challenge
Abuse pressure Repeated sends create inbox noise Repeated sends consume a metered delivery path Share limits across both channels
Cost model Evaluate total operational and support load Evaluate variable delivery and abuse exposure Review cost after reliability and security constraints

This table deliberately produces no universal winner. For a signup verification link, choose the primary channel whose redemption and completion behavior meets the product's reliability objective in each supported region. If both qualify, consider user friction, recovery coverage, privacy, support burden, and cost. Price comes late because a cheap message that does not complete verification has failed its job.

The regional split is a starting dimension, not an excuse to make claims about every US or EU recipient. Carrier, mailbox, device, and traffic mix can vary inside either group. Promote a finer dimension only when it changes an engineering decision and has enough traffic to interpret responsibly.

Put rate limits in the threat model

Fallback is useful when it is a controlled transition in the same challenge. It is dangerous when each click creates a fresh credential and a fresh send budget. The following in-memory example shows the policy boundary; a production deployment needs shared, atomic storage so multiple processes enforce the same counters.

type Attempt = {
  challengeId: string;
  accountKey: string;
  destinationKey: string;
  channelsTried: Set<Channel>;
  sends: number;
  createdAtMs: number;
};

const MAX_SENDS_PER_CHALLENGE = 3;
const CHALLENGE_WINDOW_MS = 10 * 60 * 1000;

function authorizeSend(
  attempt: Attempt,
  channel: Channel,
  nowMs: number
): { allowed: true } | { allowed: false; reason: string } {
  if (nowMs - attempt.createdAtMs >= CHALLENGE_WINDOW_MS) {
    return { allowed: false, reason: "expired" };
  }

  if (attempt.sends >= MAX_SENDS_PER_CHALLENGE) {
    return { allowed: false, reason: "challenge_limit" };
  }

  if (attempt.channelsTried.has(channel)) {
    return { allowed: false, reason: "duplicate_channel" };
  }

  return { allowed: true };
}
Enter fullscreen mode Exit fullscreen mode

The numbers here are example policy values, not best-practice constants. Tune them against the threat model and measured completion latency. The important mechanism is shared accounting: account, destination, challenge, and network-level controls should feed one decision before any adapter sends. Return a generic response so the signup surface does not reveal whether an account or destination exists.

A fallback request should preserve the challenge identity, record the reason offered to the user, invalidate superseded credentials according to the chosen policy, and emit a transition event. That creates a trail from primary failure to fallback completion without logging the secret itself.

Retries deserve restraint. Retry only failure classes that the transport contract marks as temporary, use bounded backoff, and retain an idempotency key across the attempt. Blind retries can transform a delay into duplicate messages and can consume the same rate-limit budget needed for a legitimate fallback.

Then the observed redemption latency supports SMS as a primary path for that cohort. Keep watching completion and failure-class rates, though. Speed at the median does not answer whether delayed or unreachable users can recover, and transport acceptance still is not completion.

Make the change as an operational experiment with a declared success measure and rollback condition. Compare equivalent signup cohorts, preserve the same challenge policy, and inspect both the typical path and the slow tail. Do not change channel, expiry, UI, and retry behavior at once; the result would be hard to attribute.

Measure the transition.

Govern fallback as account access

It can. The effective verification strength cannot exceed the weakest path that grants the same account state. A convenient fallback that bypasses the controls on the primary path becomes the preferred route for an attacker too.

That does not make fallback automatically wrong. It means fallback needs the same threat review, shared attempt budget, expiry rules, enumeration resistance, and audit trail as the primary path. For higher-risk actions, signup verification and later authentication should not be treated as interchangeable evidence merely because both use a one-time credential.

The durable decision rule is straightforward: select a normal channel from measured redemption and completion behavior, retain fallback only where it adds meaningful recovery coverage, and operate both through one observable, rate-limited state machine. Revisit the choice when the funnel changes. Delivery is a production signal, not a vendor promise.

References

Top comments (0)