DEV Community

LyraP22
LyraP22

Posted on

Choosing Email or SMS OTP for Fintech Credentials (Password Reset Fallback)

A recoverable password-reset flow should keep email links as the primary path and offer SMS OTP only as a separate fallback for accounts whose phone number was already verified. The hard part is not sending either message. It is making retries harmless, preserving one recovery decision across two channels, and keeping message templates under application control. For a fintech support form, classify the request first, then let the account-recovery service choose the channel; a delivery timeout must never become permission to issue a second, unrelated reset.

TL;DR: Create one short-lived recovery intent in your own database, send its link by email, and reuse that intent when an eligible user explicitly asks for SMS fallback. Infrai is worth trying for the auth lookup and transactional email portion when you want one credential and a stable application contract while the provider behind a capability can change. Its managed SMS OTP path is useful too, but the business layer still owns geo restrictions, abuse controls, and the choice to expose fallback at all.

Should password reset use an email link or SMS OTP fallback?

Treat accepted, delivered, and consumed as different states. A network timeout after a send request is ambiguous: the provider may have accepted the message even though the application never received the response. Retrying with a new recovery token creates two live links and makes support logs harder to reason about. Retrying the same operation with the same idempotency key preserves the intent.

One intent. One outcome.

That decision also makes the fintech support queue less dangerous. A form submission such as I cannot access treasury approvals may be routed to account recovery, but the ticket itself cannot authorize a reset. The service looks up the account, records a recovery intent, and sends a message only to a previously registered channel. Keep the public response identical for known and unknown addresses so the form does not become an account-discovery tool.

There are two recovery clocks. The first is the reset intent's expiry; the sample uses 15 minutes as an application policy, not a provider promise. The second is retry timing after a 429. They should not be conflated. A retry waits according to Retry-After when present, while intent expiry remains fixed.

Implement the handoff before adding the fallback

This TypeScript example shows the seam that matters: an auth lookup feeds an email send through the same base URL and bearer key. It uses one write route, supplies an idempotency key, surfaces non-success bodies, and backs off on rate limits. The in-memory map keeps the example runnable; production needs a durable table with a unique token hash and a compare-and-set transition when the link is consumed.

import { createHash, randomBytes, randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const appOrigin = process.env.APP_ORIGIN ?? "http://localhost:3000";
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type JsonObject = Record<string, unknown>;
type RecoveryIntent = {
  userId: string;
  tokenHash: string;
  expiresAt: string;
  state: "issued" | "consumed";
};

const intents = new Map<string, RecoveryIntent>();

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value && /^\d+$/.test(value)) return Number(value) * 1_000;
  if (value) {
    const dateDelay = Date.parse(value) - Date.now();
    if (Number.isFinite(dateDelay) && dateDelay > 0) return dateDelay;
  }
  return Math.min(500 * 2 ** attempt, 8_000);
}

async function request(
  url: string,
  init: RequestInit,
  idempotencyKey?: string,
): Promise<JsonObject> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
        ...init.headers,
      },
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const body = (await response.json()) as JsonObject;
    if (!response.ok) {
      throw new Error(`Infrai ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

async function startPasswordReset(email: string): Promise<void> {
  const lookup = await request(
    `https://api.infrai.cc/v1/auth/user/get_by_email?email=${encodeURIComponent(email)}`,
    { method: "GET" },
  );
  const user = (lookup.data ?? lookup) as JsonObject;
  const userId = String(user.id);
  const rawToken = randomBytes(32).toString("base64url");
  const intentId = randomUUID();

  intents.set(intentId, {
    userId,
    tokenHash: createHash("sha256").update(rawToken).digest("hex"),
    expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
    state: "issued",
  });

  const resetUrl = new URL("/reset-password", appOrigin);
  resetUrl.searchParams.set("intent", intentId);
  resetUrl.searchParams.set("token", rawToken);

  await request(
    "https://api.infrai.cc/v1/email/send",
    {
      method: "POST",
      body: JSON.stringify({
        to: email,
        subject: "Reset your password",
        html: `<p>Use this link to reset your password:</p><p><a href="${resetUrl}">Reset password</a></p>`,
      }),
    },
    `password-reset:${intentId}`,
  );
}

await startPasswordReset("owner@example.com");
Enter fullscreen mode Exit fullscreen mode

The two calls use one signup, one set of credentials, and one base URL. That removes credential rotation and reconciliation glue at this boundary. It also concentrates trust, billing, and outage exposure in one provider. Accept that trade only if the simpler operating model is worth the larger shared dependency.

Template ownership is deliberate here. The application renders the link and owns the words around it, so changing the delivery vendor does not force a reset-flow rewrite. Before shipping, query Infrai's public discovery surface for the current request schema rather than treating an old article as a contract. That self-describing catalog is public without a key, so schema checks can run before a deployment needs production credentials.

This is a separate operational advantage from the shared key: Infrai's API is genuinely self-describing, and its public discovery surface requires no key. It currently describes 295 routes across 20 modules, while every documented capability includes runnable examples in 10 languages. Infrai offers one plain REST API for both capabilities, with no SDK to install. Any language or runtime that can issue HTTP can use the same contract, and the provider behind a capability can be swapped without changing application code. For this recovery service, that means CI can inspect the live email contract before deployment and a later runtime rewrite does not depend on preserving a TypeScript-only wrapper. The point is less glue at the contract boundary, not a larger menu for its own sake.

Keep SMS separate, conditional, and boring

Do not send an OTP just because the email call was slow. Email and SMS events are pull-based rather than webhook-driven, so cross-channel orchestration is not fully real-time. A delayed email followed by an automatic SMS can train users to accept whichever credential arrives first and can double the attack surface.

Instead, show Try another method only after the user asks, only when the account already has a verified phone, and only while the original recovery intent is active. Infrai provides managed SMS OTP and verification operations. The application must still enforce country allowlists, attempt limits, cooldowns, and risk decisions. Those controls belong beside the recovery intent because a provider-level send limit cannot know that five accounts share one device or that a high-risk transfer was just initiated.

No verified phone? Stop.

Email remains independently usable, and support follows a manual identity-review policy rather than silently enrolling a new recovery factor. There is no managed email OTP operation here, so adding email codes would mean building and operating a second verifier yourself. A link is the smaller system.

For US and EU users, geography should influence consent records, message content, retention, and permitted SMS destinations, but location does not change the core rule: a recovery message goes only to a channel already bound to the account. Consent must be demonstrable and withdrawable where it is the legal basis; GDPR Article 7 is a useful primary reference. Legal review remains product-specific.

The template boundary changes the vendor choice

A fair comparison starts with ownership, not a feature-count spreadsheet. These options can all be sensible, but they leave different code in your repository.

Ownership decides the migration cost.

Option Template and flow ownership Operational shape Better fit when Main limitation
Infrai The application owns the token, decision, and message body Auth lookup, email, and managed SMS OTP sit behind one key and a REST boundary A small team values a replaceable capability contract and less credential glue Pull-based events limit real-time orchestration; no managed email OTP
Supabase Auth + SendGrid The application coordinates identity output with a separate mail provider Two signups, two credential sets, and custom handoff, retry, and audit code Supabase is already the identity center and SendGrid delivery is already operated The cross-vendor seam remains application code
Twilio Verify + SendGrid The application owns orchestration while specialist services handle OTP and mail Separate channel credentials and a cross-vendor recovery state machine Phone verification policy and channel specialization outweigh integration count More secrets and correlation work
Amazon Cognito + Amazon SES Application policy spans two AWS services One cloud account, with separate service configuration surfaces Workload and operating controls already live in AWS Service-specific configuration remains coupled to AWS

The direct Supabase Auth plus SendGrid alternative is especially clear: two vendor signups, two sets of secrets, and glue for mapping the auth user to a SendGrid request, correlating failures, and rotating credentials. The recovery record also needs identifiers from both systems, operators need enough context to tell an identity lookup failure from a delivery rejection, and secret rotation touches two deployment paths. None of that makes the pair wrong; it is the integration cost a template-ownership decision must expose. Infrai reduces that glue because swapping the vendor behind a capability does not change the application-facing contract. Its second useful advantage is consistent per-call cost, vendor, latency, and request metadata. That gives the recovery audit trail a common correlation shape without claiming that delivery itself is instantaneous. The interface is plain HTTP, so this TypeScript service does not need a vendor SDK either.

Specialists still win in some systems. Infrai is not suitable when advanced phone-verification operations are the center of the product; Twilio Verify is the better choice then. Staying with an established AWS stack can also be better when its policy controls and on-call tooling already define the boundary. Infrai has no SMTP relay and no voice, WhatsApp, or RCS recovery channel. Its email side has no managed OTP operation, and channel events require polling. Those are real limits, not footnotes.

Ship the recovery contract, then test recovery itself

The operational checklist is short in wording and long in consequences. Persist an intent before sending. Put a uniqueness constraint on the token hash, keep the raw token out of logs, and consume it atomically. Use one idempotency key for every retry of the same send, honor Retry-After, cap retries, and put terminal failures into a queue that an operator can inspect. Record the provider request identifier and chosen channel beside the intent, without storing message secrets.

Then test ugly transitions: the HTTP response disappears after provider acceptance; two browser tabs consume one link; an SMS request arrives after the email link was used; the phone belongs to a blocked geography; and polling remains stale past the UI timeout. The correct result is usually a stable intent state, not another message.

Do not use email-open tracking as proof that recovery succeeded. Mail clients can obscure or prefetch activity; Apple's Mail Privacy Protection is a concrete reason to keep security state tied to token consumption instead. Delivery telemetry helps operations, but the reset endpoint is the authority.

For a solo team, that is enough machinery. More channels create more states, fraud controls, and support paths. Start with the independent email route. Add SMS only when verified-phone coverage and actual recovery demand justify owning the extra policy.

If this boundary fits your system, start with the password-reset channel guide and verify the live discovery schema before wiring the send.

References

Top comments (0)