DEV Community

EvanShepherd8274
EvanShepherd8274

Posted on

Healthtech 2FA Login Codes: Auditable Email Fallback After SMS Polling

Short answer: for a healthtech password reset, keep one short-lived OTP active, make email fallback an explicit user action, poll message status only for delivery evidence, and keep authentication decisions inside your own challenge state.

The constraint that changes the design is compliance evidence. A solo SaaS does not need a sprawling communications control plane. It needs to answer a smaller set of awkward questions later: which policy applied, which channel was requested, when the code expired, how many verification attempts occurred, and whether the code was invalidated. The evidence must be useful without preserving the code or copying a phone number and email address into every log line.

This distinction matters: a delivery provider reports on a message, while the application decides whether a login or password-reset challenge succeeds. Mixing those two states makes fallback hard to reason about and harder to audit. Ship the boring boundary first.

The audit test starts with the authentication event ledger

Treat the password-reset flow as a state machine, not as two send calls. The state belongs to the application and has a policy version, an expiry, an attempt counter, one currently valid code digest, and the selected channel. Sending a replacement code by email invalidates the SMS code. A late SMS can still arrive, but it cannot reopen an older authentication state.

Evidence first.

OWASP's forgot-password guidance provides the useful baseline: return a consistent message and response time for existing and nonexistent accounts, use a cryptographically secure random generator, store reset material securely, rate-limit attempts, expire it, and invalidate it after use. Those rules are more important than the transport. An SMS marked delivered is not proof that the intended person received it; an email receipt is not an authentication result either.

For evidence, I would record challengeId, a pseudonymous account key, policyVersion, regionPolicy, channel, event name, request ID, message ID, and timestamp. I wouldn't record the OTP, raw destination, email body, or provider credential. Whether message IDs and pseudonymous identifiers count as personal data under a particular US or EU deployment is a policy question, not something application code can settle. I'm not sure a generic retention period is defensible across both regions; counsel and the data owner should set it, then the service should enforce that versioned rule.

Keep the reset email transactional. The FTC explains that CAN-SPAM treatment depends on a message's primary purpose and that transactional or relationship messages have a different set of requirements from commercial email. Don't attach a promotion to a security message and then expect the label “password reset” to settle the classification.

The audit trail can stay compact:

Event Keep Do not keep
challenge.created policy version, expiry, pseudonymous account key OTP, raw phone, raw email
message.submitted channel, message ID, request ID message body, provider secret
message.observed normalized delivery state, observed time a claim that delivery authenticated the user
challenge.replaced old challenge ID, new challenge ID, reason the replaced code
challenge.verified result category, attempt count, time submitted OTP

One detail is easy to miss. The public reset endpoint should give the same generic response whether the account exists or not, while the internal worker creates a real challenge only for an eligible account. That split preserves the outward behavior OWASP calls for without fabricating audit events for identities the system does not know.

How should a Node.js SaaS poll SMS and email OTP fallback without webhooks?

Use a bounded polling worker behind a provider-neutral adapter. Polling owns delivery visibility; it does not own the OTP lifecycle. The browser should never call the messaging provider, and it should not learn raw provider errors. It can receive a coarse state such as sent, still_checking, or fallback_available from the application.

A practical flow has five transitions. First, the reset endpoint performs its private eligibility check and always returns the same public response. Second, the application creates an SMS challenge under a versioned policy and submits the message. Third, a worker polls the resulting message ID for a limited window, recording normalized observations. Fourth, the UI may offer email after the policy's waiting condition; the user must request that change. Fifth, the application replaces the challenge before sending the email code, so only one digest can verify.

No automatic downgrade.

That rule prevents a transient delivery observation from silently moving an account to a second channel. It also makes the evidence plain: the user requested fallback, the application replaced challenge A with challenge B, and the second transport carried B. The catch is extra friction. For a low-risk consumer app, that click may feel fussy. For a healthtech account where recovery can expose sensitive records, an explicit transition is easier to defend than an invisible channel switch.

Polling intervals and budgets are policy inputs, not universal best-practice numbers. The sample below uses a 2-second interval and a 12-second budget to make the mechanism concrete. Your mileage may vary — provider rate limits, expected delivery time, and login traffic should decide the production values. Add jitter in a multi-worker deployment so a burst of reset requests does not synchronize every status read.

Do not turn failed into “email this person now.” A delivery failure can make fallback available, but a user action or a separately approved risk policy should authorize the channel change. Likewise, do not keep polling until the OTP expires. A bounded observer costs less, produces a finite evidence record, and leaves authentication state independent from provider availability.

A working code example with one active challenge

The core can fit behind two interfaces: a challenge repository and a message transport. The transport adapter translates provider-specific delivery vocabulary into four local states. The repository supplies atomic replacement and attempt updates; those operations must be transactional in a real database, even though their storage implementation is outside this example.

All timing and risk choices live in Policy. That is deliberate. A weekly shipping cadence works only when changing a retention or expiry decision doesn't require rewriting transport code.

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

type Channel = "sms" | "email";
type DeliveryState = "queued" | "delivered" | "failed" | "unknown";

type Policy = {
  version: string;
  expiresInMs: number;
  maxAttempts: number;
  pollEveryMs: number;
  pollBudgetMs: number;
};

type Challenge = {
  id: string;
  accountKey: string;
  channel: Channel;
  codeDigest: Buffer;
  expiresAt: number;
  attempts: number;
  usedAt?: number;
  replacedAt?: number;
  policyVersion: string;
};

interface ChallengeRepository {
  insert(challenge: Challenge): Promise<void>;
  find(id: string): Promise<Challenge | undefined>;
  incrementAttempts(id: string): Promise<Challenge>;
  markUsed(id: string, usedAt: number): Promise<void>;
  replace(currentId: string, next: Challenge, replacedAt: number): Promise<void>;
}

interface MessageTransport {
  sendOtp(input: {
    channel: Channel;
    destination: string;
    code: string;
    expiresAt: number;
  }): Promise<{ messageId: string }>;
  getDelivery(messageId: string): Promise<DeliveryState>;
}

interface AuditWriter {
  write(event: {
    name: string;
    challengeId: string;
    at: number;
    details: Record<string, string | number>;
  }): Promise<void>;
}

const delay = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

function makeCode(): string {
  return randomInt(0, 1_000_000).toString().padStart(6, "0");
}

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

async function issueChallenge(input: {
  accountKey: string;
  channel: Channel;
  destination: string;
  policy: Policy;
  secret: string;
  repository: ChallengeRepository;
  transport: MessageTransport;
  audit: AuditWriter;
  replaces?: string;
}): Promise<{ challengeId: string; messageId: string }> {
  const now = Date.now();
  const code = makeCode();
  const challenge: Challenge = {
    id: randomUUID(),
    accountKey: input.accountKey,
    channel: input.channel,
    codeDigest: digest(code, input.secret),
    expiresAt: now + input.policy.expiresInMs,
    attempts: 0,
    policyVersion: input.policy.version,
  };

  if (input.replaces) {
    await input.repository.replace(input.replaces, challenge, now);
  } else {
    await input.repository.insert(challenge);
  }

  const { messageId } = await input.transport.sendOtp({
    channel: challenge.channel,
    destination: input.destination,
    code,
    expiresAt: challenge.expiresAt,
  });

  await input.audit.write({
    name: "message.submitted",
    challengeId: challenge.id,
    at: Date.now(),
    details: { channel: challenge.channel, messageId },
  });

  return { challengeId: challenge.id, messageId };
}

async function observeDelivery(input: {
  challengeId: string;
  messageId: string;
  policy: Policy;
  transport: MessageTransport;
  audit: AuditWriter;
}): Promise<DeliveryState> {
  const stopAt = Date.now() + input.policy.pollBudgetMs;
  let state: DeliveryState = "unknown";

  while (Date.now() < stopAt) {
    state = await input.transport.getDelivery(input.messageId);
    await input.audit.write({
      name: "message.observed",
      challengeId: input.challengeId,
      at: Date.now(),
      details: { messageId: input.messageId, state },
    });
    if (state === "delivered" || state === "failed") return state;
    await delay(input.policy.pollEveryMs);
  }

  return state;
}

async function verifyCode(input: {
  challengeId: string;
  submittedCode: string;
  secret: string;
  policy: Policy;
  repository: ChallengeRepository;
}): Promise<boolean> {
  const found = await input.repository.find(input.challengeId);
  if (!found || found.usedAt || found.replacedAt || Date.now() >= found.expiresAt) {
    return false;
  }

  const current = await input.repository.incrementAttempts(found.id);
  if (current.attempts > input.policy.maxAttempts) return false;

  const submitted = digest(input.submittedCode, input.secret);
  const valid = timingSafeEqual(current.codeDigest, submitted);
  if (valid) await input.repository.markUsed(current.id, Date.now());
  return valid;
}
Enter fullscreen mode Exit fullscreen mode

replace is the critical operation. It must mark the current challenge replaced and insert the new email challenge atomically. incrementAttempts also has to be atomic, or two concurrent submissions can both observe the same attempt count. The send occurs after persistence, which gives an audit anchor before any external call; a production worker can take that persisted intent from a queue and submit it without keeping an OTP in logs.

There is still an operational choice around the cleartext code between creation and send. Keep that handoff in memory or in an encrypted, access-controlled job payload with a very short lifetime. The code sample keeps it in one call frame. It never returns the code to the browser and never writes it through AuditWriter.

Test the state transitions, not a vendor sandbox. Use a fake MessageTransport that returns queued, unknown, and delivered in a fixed sequence. Assert that the observer stops at its budget, a replaced SMS code fails verification, the email code remains subject to the original attempt and expiry policy you chose, and a successful code cannot be replayed. Then run a smaller contract suite against each real adapter to verify its status mapping.

Retry boundaries beyond one polling worker

At modest reset volume, one queued observer per submitted message is understandable and cheap to operate. At scale, move polling into a scheduled queue, add jitter and per-provider concurrency limits, and deduplicate observations by message ID plus normalized state. Partition audit storage by the retention policy that actually applies; “US” and “EU” labels alone are not retention rules. Monitor time to first terminal observation, poll count per message, fallback-request rate, verification failures, and rate-limit rejections. Those metrics expose a bad policy without treating delivery as identity proof.

Polling is not suitable when you need near-real-time delivery events across high message volume or when status reads consume a tight provider quota. Use authenticated webhooks when they are available and operationally acceptable, while retaining the same local state machine. If the application cannot obtain trustworthy delivery status at all, stop presenting status as fact: show that the code was sent, allow a policy-timed resend or explicit fallback, and rely on challenge verification for the security decision.

Email fallback is also the wrong recovery mechanism for accounts whose email and password share the same compromised inbox, or where the risk model requires phishing-resistant authentication. In that case, stick with a separately enrolled recovery factor or a reviewed support recovery process. SMS and email are useful possession channels, but adding a second delivery path does not automatically make account recovery high assurance.

For a one-person SaaS, the revenue-per-hour test is blunt: outsource message delivery, but own the tiny state machine and evidence contract that differentiate a trustworthy recovery flow. Ship weekly. Revisit the transport only when measured volume, provider limits, or the approved risk policy makes the current boundary wrong.

References

Top comments (0)