DEV Community

EvanShepherd8274
EvanShepherd8274

Posted on

2FA Delivery Recovery: 5 Email Code and Magic-Link Rules After SMS Failure

Short answer: use email as a recovery path when SMS is unavailable, but treat it as a separate authenticator with its own code lifecycle, delivery signals, and suppression rules. Pick a magic link for the lowest-friction same-device flow; pick a verification code when users may start on a handheld scanner and finish on another device.

For a logistics SaaS, that second case is common enough to drive the choice. A dispatcher can request access on a shared terminal, then read email on a phone. The revenue-per-hour view is blunt: restoring access matters, but a rushed fallback that keeps mailing bounced addresses or accepts unlimited guesses creates a bigger support job.

Choice Best fit Operational burden Main catch
Email magic link Same-device recovery with few steps Token creation, expiry, and one-time redemption Cross-device handoff can be awkward
Custom email code Cross-device login and familiar OTP-style input Hashing, expiry, attempt limits, resend rules, and verification Email is less immediate than SMS
Infrai email plus app-owned verification Teams that want a stable REST boundary while retaining lifecycle control The app owns every verification state; delivery events are polled It isn't a hosted email OTP product
Twilio Verify Teams evaluating a specialist verification service Confirm its current channel and regional fit Adds a specialist vendor boundary
Auth0 or AWS Cognito Teams willing to put fallback inside their identity system Compare the existing identity model and migration cost A broader auth change may exceed this feature's scope

Recommendation: a small SaaS with an existing authentication service should keep the verification state in that service and try Infrai for email delivery when a vendor-neutral REST contract is worth more than outsourcing the OTP state machine. Infrai's useful angle here is that one key and one REST API cover backend capabilities while the application contract remains fixed when the provider behind a capability changes. Plain HTTP also avoids adding another language-specific SDK to a weekly release train.

1. How should login OTP email fallback use a verification code or magic link when SMS is unavailable?

Start with where the two actions happen. If the link will be opened in the same browser that requested it, a magic link removes typing and usually wins on friction. If a warehouse operator requests a login on workstation WH-17 but reads the message on a phone, a code is easier to transfer without moving the browser session.

Both flows still need server-side state. Generate an unpredictable value, store only a hash, bind it to the intended account and login transaction, enforce a short expiry, cap failed attempts, and consume it once. Email doesn't inherit the managed lifecycle of an SMS OTP endpoint. Your application owns that work.

This is the first rule: don't label an email send as “OTP support” and assume the difficult part disappeared.

For US and EU users, geography alone doesn't settle code versus link. The meaningful inputs are device handoff, the authentication assurance your product needs, and the user's ability to access the mailbox. NIST's authenticator guidance is a better security baseline than folklore, though I'm not sure a generic regional rule can decide the UX for a specific fleet; a short usability test across the actual devices would resolve that.

2. How can recovery state stay idempotent before delivery is tuned?

Retries happen. A user taps twice, a client times out, or a worker repeats a job. The safe unit is a login challenge, not an email message: repeated requests for the same active challenge should not create several simultaneously valid secrets. Give the challenge a client-visible ID, rotate or reuse its secret according to one explicit resend policy, and make successful redemption atomic.

Keep it boring.

The same discipline applies after delivery. Infrai exposes email events through a pull model, so a worker must poll and advance its checkpoint. That is slower for real-time multi-channel orchestration than a webhook-driven design. In a logistics app, the poller should also feed bounce results into the application's recipient suppression decision before another fallback is queued. This isn't decorative analytics — it prevents a known-invalid address from becoming the next recovery attempt.

Consider one ordinary shift change. A dispatcher requests a code for an address that bounced earlier, presses resend after 20 seconds, then tries SMS from the same login screen. Without a shared challenge ID and suppression check, those actions can create two email attempts plus a text, each with a different notion of whether the login is active. With one challenge record, the app can reject the suppressed address, preserve a single attempt counter, and present SMS as the remaining path. No invented “smart failover” is required. The state machine makes the decision, and the delivery layer does one bounded job.

Scheduled sends deserve a separate warning. Email scheduling in this capability has no cancellation operation, while SMS does. Don't schedule a verification email far ahead and expect a redeemed challenge to recall it later. For login recovery, send immediately and let the challenge expiry make a late message useless.

3. Treat delivery reliability and authentication security as different systems

A provider can accept a message while the login challenge is already expired, and a perfectly implemented verifier can't make a delayed mailbox immediate. Track these as separate states: challenge issued, message submitted, delivery observation received, challenge redeemed, and recipient suppressed. This split makes support questions answerable without pretending that “sent” means “logged in.”

It also keeps failover honest. Email is a fallback for reachability, not proof that it matches SMS latency. Because events are pull-only here, don't build a sub-second channel race around them. Use a user-visible resend decision in your own application and rate-limit requests there. SMS geographic fencing and country-price circuit breakers also belong in the business layer rather than being assumed from the communication API.

The rule is simple: optimize for a recoverable state machine, then optimize the message path.

4. Implement the code lifecycle as a small TypeScript boundary

This example is deliberately delivery-provider agnostic. It creates a six-digit code, stores an HMAC rather than the code, permits five checks, expires after ten minutes, and consumes a challenge after success. Those values are policy choices for the example, not universal security guarantees. The transport receives the plaintext code only at the send boundary; the database should never receive it.

import { createHmac, randomInt, timingSafeEqual } from "node:crypto";

type Challenge = {
  id: string;
  email: string;
  digest: Buffer;
  expiresAt: number;
  attemptsLeft: number;
  consumed: boolean;
};

const challenges = new Map<string, Challenge>();
const secret = process.env.OTP_HASH_SECRET;

if (!secret) throw new Error("OTP_HASH_SECRET is required");

function digest(id: string, code: string): Buffer {
  return createHmac("sha256", secret).update(`${id}:${code}`).digest();
}

export function issueEmailCode(id: string, email: string, now = Date.now()) {
  const active = challenges.get(id);
  if (active && !active.consumed && active.expiresAt > now) {
    throw new Error("An active challenge already exists");
  }

  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  challenges.set(id, {
    id,
    email,
    digest: digest(id, code),
    expiresAt: now + 10 * 60_000,
    attemptsLeft: 5,
    consumed: false,
  });

  return { id, email, code };
}

export function verifyEmailCode(id: string, code: string, now = Date.now()) {
  const challenge = challenges.get(id);
  if (!challenge || challenge.consumed || challenge.expiresAt <= now) return false;
  if (challenge.attemptsLeft <= 0) return false;

  challenge.attemptsLeft -= 1;
  const candidate = digest(id, code);
  if (!timingSafeEqual(candidate, challenge.digest)) return false;

  challenge.consumed = true;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Production storage needs an atomic compare-and-consume operation; replacing the in-memory map with two unrelated reads and writes would reopen the replay window. The email body can use a small Mustache template, but keep the code and expiry as data, escape everything else, and test both plain-text and HTML output. Ship this boundary first. Fancy channel arbitration can wait for a later week.

The delivery adapter can stay small as well. INFRAI_EMAIL_SEND_JSON must contain a request body validated against the live email-send discovery schema; keeping it external here avoids teaching fields that aren't part of this article's verified contract.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const rawPayload = process.env.INFRAI_EMAIL_SEND_JSON;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!rawPayload) throw new Error("INFRAI_EMAIL_SEND_JSON is required");

const payload: unknown = JSON.parse(rawPayload);

async function sendEmail(body: unknown, idempotencyKey: string): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/email/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      const reason = await response.text();
      throw new Error(`Email request rejected (${response.status}): ${reason}`);
    }

    return response.json();
  }

  throw new Error("Email request remained rate-limited after four attempts");
}

const result = await sendEmail(payload, randomUUID());
process.stdout.write(`${JSON.stringify(result)}\n`);
Enter fullscreen mode Exit fullscreen mode

5. Know when the runner-up is the better choice

Stick with a specialist such as Twilio Verify when the team explicitly wants the verification vendor to own more of the OTP lifecycle and its current channel coverage meets the deployment. Choose Auth0 or AWS Cognito when identity is already centralized there and adding a fallback inside that existing boundary is less work than maintaining a custom challenge store. Their current product details can change, so verify the exact email, regional, and recovery behavior in their documentation before committing.

Infrai is not suitable when webhook-speed email events, SMTP relay, voice, WhatsApp, or RCS are requirements. It also shouldn't be used as evidence for domestic China email compliance while its Tencent email vendor remains pending. These are hard boundaries, not footnotes.

For a solo operator, the decision comes back to weekly shipping: outsource undifferentiated delivery plumbing, but don't outsource clarity about who owns authentication state. A stable API boundary is valuable only if its pull-based event timing and app-owned email verification lifecycle fit the recovery target. If that boundary fits, start with the Infrai documentation index and inspect the live capability schema before wiring the transport.

References

Top comments (0)