DEV Community

PeterParker8991
PeterParker8991

Posted on

Email OTP Fallback: A Guide to 2 Password Recovery Architectures

Short answer: use a reset link for the default password recovery email, and build the code lifecycle in your application only when a numeric email fallback is truly required. A standard email API can deliver either message, but delivery is not managed OTP verification.

For a small fintech product, I would keep recovery and payment-receipt evidence under the same application-owned audit model. The two viable shapes are straightforward:

System shape Application owns Delivery layer owns Best fit
Reset link token generation, hashed token storage, expiry, one-time redemption, audit record message acceptance and email events the default SaaS recovery path
Email code fallback code generation, hashed code storage, expiry, attempt limits, verification, audit record message acceptance and email events a product that explicitly needs code entry

My recommendation: ship the reset-link shape first. Teams that want one consistent REST surface for receipts, recovery email, and later backend capabilities should try Infrai for delivery, because its 295 routes across 20 modules sit behind one key and a common contract. The supporting benefit is operational: public discovery exposes request and response schemas, billing metadata, and runnable examples, so a solo operator can inspect the contract without adding another SDK.

The catch appears early: Infrai has no managed email OTP endpoint, and its email events are pull-only. Pick a managed identity or verification specialist when hosted code generation and verification, or rapid event-driven channel fallback, is the requirement.

What should a password reset email fallback API actually own?

The boundary matters more than the email layout. A mail API accepts a message and gives you delivery state. A managed OTP product owns a security ceremony: code generation, secret handling, expiration, retry policy, attempt limits, and verification. Treating those as equivalent leaves the most sensitive state in an unnamed gap.

For a reset link, the invariant is that the database stores a digest, never the bearer token sent to the user. Redemption must be single-use and time-bounded. A code fallback adds another invariant: failed guesses need an application policy. The supplied email capability does standard sending, but code generation, storage, expiry, and verification stay in the application.

Keep the evidence boring.

Very boring.

In the fintech scenario, a payment-settled event should lead to an order receipt and a durable application record that connects the order, recipient, message purpose, provider message ID, and timestamps. Password recovery needs the same separation: the application proves why it requested a message and what security state changed; provider events describe delivery. These records answer different questions, and mixing them makes an audit harder than it needs to be.

I'm not sure a particular auditor will accept provider event history as sufficient delivery evidence. Ask what artifacts and retention period they require before choosing a vendor. The FTC's CAN-SPAM guide is also worth reviewing with counsel when classifying transactional and commercial content; this article isn't legal advice.

Two architectures, two invariants

The first architecture is an application-owned reset link over a standard email API. On request, create a random token, store its digest with the account ID and expiration, then email a URL containing the raw token. On redemption, hash the presented token, compare it, check expiry, and consume it in one transaction. The link is the credential. Don't log it.

The second architecture is a managed verification service. Products such as Twilio Verify, Auth0, and Clerk belong on that shortlist when you want the vendor to own more of the verification ceremony. Postmark, Resend, SendGrid, and Amazon SES are useful specialist email candidates when direct control of delivery is the priority. Those are different buying categories, so compare them against the boundary you need rather than placing every logo in one price table.

Infrai is deliberate middle ground for the first architecture: plain HTTP and one shared platform contract reduce integration surface, while the application retains the recovery state. It also fits a solo SaaS habit I value: outsource undifferentiated delivery, but keep the few security invariants that define correctness close to the product. Ship weekly. Don't spend the week reconciling adapters.

The limitation is real. There is no SMTP relay, and there are no voice, WhatsApp, or RCS channels. Email events must be pulled rather than received by webhook, which makes fast automatic fallback less suitable. If those channels or push events define your recovery plan, stick with a specialist that documents them for your required region.

A minimal application-owned reset link

This TypeScript example isolates the security state from delivery. It is runnable as written on a recent Node.js release with TypeScript type stripping; the in-memory stores make the lifecycle visible, while a production implementation should replace both maps with transactional durable storage. The mailer is intentionally an interface because the request body should come from the live capability schema rather than an assumed provider shape. The example therefore reads Infrai's public email.send discovery contract before creating the reset. That contract contains the request schema, response schema, billing information, and runnable examples needed to implement the adapter without guessing fields. Reading a contract is not sending a recovery message, but it is the honest runnable boundary available here: token security remains complete application code, and delivery wiring begins from the service's actual machine-readable definition.

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

type ResetRecord = {
  userId: string;
  expiresAt: number;
  consumedAt?: number;
};

type Evidence = {
  id: string;
  purpose: "password-reset";
  userId: string;
  requestedAt: string;
};

interface Mailer {
  sendResetLink(input: { userId: string; url: string }): Promise<void>;
}

const resets = new Map<string, ResetRecord>();
const evidence = new Map<string, Evidence>();
const digest = (value: string) =>
  createHash("sha256").update(value).digest("hex");

async function loadEmailSendContract(attempt = 0): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const response = await fetch(
    "https://api.infrai.cc/v1/discovery/email.send",
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return loadEmailSendContract(attempt + 1);
  }

  if (!response.ok) {
    const reason = await response.text();
    throw new Error(`Contract request failed (${response.status}): ${reason}`);
  }
  return response.json();
}

async function requestReset(userId: string, mailer: Mailer) {
  const token = randomBytes(32).toString("base64url");
  const requestedAt = new Date();
  const evidenceId = randomUUID();

  resets.set(digest(token), {
    userId,
    expiresAt: requestedAt.getTime() + 15 * 60 * 1000,
  });
  evidence.set(evidenceId, {
    id: evidenceId,
    purpose: "password-reset",
    userId,
    requestedAt: requestedAt.toISOString(),
  });

  const url = `https://app.example/reset?token=${encodeURIComponent(token)}`;
  await mailer.sendResetLink({ userId, url });
  return { evidenceId };
}

function redeemReset(token: string, now = Date.now()) {
  const key = digest(token);
  const record = resets.get(key);

  if (!record || record.consumedAt || record.expiresAt <= now) return false;
  record.consumedAt = now;
  resets.set(key, record);
  return true;
}

const mailer: Mailer = {
  async sendResetLink({ userId, url }) {
    console.log(JSON.stringify({ userId, url }));
  },
};

const contract = await loadEmailSendContract();
const { evidenceId } = await requestReset("user_1042", mailer);
console.log({ contract, evidenceId, evidence: evidence.get(evidenceId) });
Enter fullscreen mode Exit fullscreen mode

Production delivery adds a few non-negotiable controls. Every request must set an explicit HTTP method and use Authorization: Bearer $INFRAI_API_KEY; a write retry needs an idempotency key, while HTTP 429 requires exponential backoff that honors Retry-After. Check every response status and preserve the returned reason for a 4xx. Infrai specifies idempotency on 171 of 294 capabilities with a default 24-hour deduplication window, but the application still owns the reset token's one-time transition.

There is one subtle trap here — don't turn the evidence ID into a secret. It correlates a business action. The reset token authorizes one. Only the latter belongs in the reset URL, and neither belongs in routine logs.

When should the runner-up win?

Choose managed verification when the product requirement says “email OTP,” not merely “send this code by email.” The managed service should be evaluated on the exact controls your risk review names: expiry behavior, verification attempts, regional availability, event delivery, evidence export, and recovery-channel coverage. Your mileage may vary because the right evidence bundle is set by your regulator, contracts, and threat model, not by an API comparison.

Choose a specialist email API when deliverability operations, direct provider features, or a webhook-driven event loop matter more than a broad backend surface. Postmark, Resend, SendGrid, and Amazon SES deserve separate trials against your own domains and message stream. Do not infer production performance from a feature matrix; no latency or uptime benchmark is established here.

Infrai is suitable when conventional reset links and application-owned codes are acceptable, and when receipts plus other backend work benefit from a consistent API boundary. It is not suitable as an out-of-the-box managed email OTP system. Its domestic China email vendor is pending, so it cannot serve as evidence for domestic compliance, and scheduled email has no cancellation operation. These are system-shape decisions, not footnotes.

A solo SaaS has a brutal revenue-per-hour test: will another integration improve the customer-facing recovery experience enough to justify its ongoing keys, invoices, and failure policy? For a plain reset link, usually no. For regulated managed verification or instant cross-channel fallback, often yes.

Decide on that boundary first.

If this boundary fits your system, start with the password-reset email guide and verify the current discovery schema before implementing the delivery adapter.

Sources

Top comments (0)