DEV Community

DrummondReed8257
DrummondReed8257

Posted on

Auditable Node.js Marketplace OTP Architecture (Primary SMS, Email Fallback Across US/EU)

Short answer: use one Node.js OTP state machine, send SMS first, allow email fallback only through an explicit policy decision, and write every delivery and suppression transition to an append-only audit log that can be polled.

For a marketplace, the hard part isn't generating six digits. It is proving why a login message was sent, which channel was attempted, whether an invalid email address was suppressed, and what the user did next across US and EU traffic. My revenue-per-hour rule points to a small design: outsource message transport, keep the policy and evidence model in the application, and ship the same contract every week.

Cheap should describe the operating surface, not the weakest security controls.

How can a beginner test Node.js OTP with SMS, email fallback, and polling?

Start with four boundaries: an OTP issuer, a channel adapter, a delivery-event normalizer, and an evidence store. The login service owns the challenge and its expiry. Adapters own transport-specific request and response shapes. The normalizer converts provider events into a small internal vocabulary. The evidence store records decisions without letting delivery callbacks mutate authentication state directly.

That last boundary matters. A delivery event answers a transport question; it does not prove that the person holding the phone or inbox is the marketplace account owner. Only successful verification of the challenge should complete login. Keep those facts apart even if they share a correlation ID.

For this system, I would use states such as created, sms_queued, sms_sent, email_queued, email_sent, verified, expired, and blocked. I would separately record delivery observations such as delivered, bounced, and rejected. The split makes awkward cases legible: an SMS may have a delivery observation while the challenge remains unverified, or an email bounce may suppress that address while the user still has an active challenge on another channel.

Polling is fine for the first operator console. Expose a cursor-based read of the application's normalized evidence log, ordered by a server-generated sequence. Don't poll two transport providers from the browser and try to reconcile their vocabularies there. The server should ingest transport events, deduplicate them, then let an internal console request entries after its last cursor. This keeps credentials off the client and gives the UI one ordering model.

The compliance record should answer a compact set of questions:

  • Which account and login challenge caused the message?
  • Which purpose, region, channel, and template version were selected?
  • What policy allowed the attempt or fallback?
  • Which provider event was received, and when was it normalized?
  • Was the destination already suppressed, and did this event change suppression?

Do not store the OTP itself in that log. Store a challenge ID and the evidence required to explain the decision. Access to the log should be narrower than access to ordinary product analytics because message destinations and authentication metadata are sensitive.

How do I roll out evidence rules before enabling the fallback path?

An email fallback button looks harmless. It can quietly become a second login path with different assumptions. A clean design evaluates fallback against account state, destination verification, suppression status, challenge age, attempt limits, and region policy. If the email address is suppressed after a hard bounce, the correct action is to refuse that channel and present an account-recovery path; repeatedly sending to a known-invalid recipient creates noise and weakens the evidence trail.

Be strict here.

I would model suppression as application data, not as a provider dashboard setting alone. Each entry needs a normalized destination identifier, channel, reason, source event ID, effective time, and optional review outcome. Event ingestion must be idempotent: receiving the same bounce twice should produce one suppression transition. A later operator action should append a review event rather than erase the original bounce. That gives support a chronology instead of a mysterious current-state flag.

US/EU routing belongs in policy too. Region is an input to template selection, approved sender identity, evidence retention, and transport routing; it should not be inferred deep inside an adapter. The precise legal and carrier requirements depend on the business, message purpose, destination, and current rules. I'm not sure a static country table is ever enough. A launch review by qualified counsel and current provider documentation resolves questions that code cannot.

SMS length also affects the path. GSM-7 and UCS-2 have different character limits and segmentation behavior, so a translated OTP message can turn into multiple segments when its character set changes. Keep authentication copy short, test the exact rendered templates, and record the template version. This is a delivery-quality reason, not a reason to squeeze required context out of the message.

Email needs its own hygiene. Treat the current Google sender guidelines as one operational baseline for authentication and sender practices, while checking the requirements that apply to the actual sending pattern. A bounce event should flow through the same normalizer as an SMS delivery event, then update the application-owned suppression policy.

The smallest working TypeScript implementation and evidence model

The core can stay boring. The example below deliberately leaves transport behind an interface. It shows the part worth owning: fallback eligibility, idempotent event ingestion, and evidence that the operator console can poll.

type Channel = "sms" | "email";
type Region = "US" | "EU";
type DeliveryKind = "accepted" | "delivered" | "bounced" | "rejected";

type Challenge = {
  id: string;
  accountId: string;
  region: Region;
  createdAt: string;
  expiresAt: string;
  verifiedAt?: string;
};

type Destination = {
  channel: Channel;
  normalized: string;
  verified: boolean;
};

type TransportEvent = {
  providerEventId: string;
  challengeId: string;
  destination: Destination;
  kind: DeliveryKind;
  observedAt: string;
};

type Evidence = {
  sequence: number;
  challengeId: string;
  action: string;
  channel: Channel;
  region: Region;
  templateVersion: string;
  sourceEventId?: string;
  recordedAt: string;
};

interface EvidenceStore {
  hasSourceEvent(id: string): Promise<boolean>;
  append(entry: Omit<Evidence, "sequence">): Promise<void>;
  suppress(destination: Destination, reason: string): Promise<void>;
  isSuppressed(destination: Destination): Promise<boolean>;
  listAfter(cursor: number, limit: number): Promise<Evidence[]>;
}

interface Transport {
  sendOtp(input: {
    challengeId: string;
    destination: Destination;
    templateVersion: string;
  }): Promise<{ requestId: string }>;
}

async function canUseEmailFallback(
  challenge: Challenge,
  email: Destination,
  store: EvidenceStore,
  now: Date,
): Promise<boolean> {
  return (
    email.channel === "email" &&
    email.verified &&
    !challenge.verifiedAt &&
    now < new Date(challenge.expiresAt) &&
    !(await store.isSuppressed(email))
  );
}

async function requestEmailFallback(
  challenge: Challenge,
  email: Destination,
  transport: Transport,
  store: EvidenceStore,
  now: Date,
): Promise<"queued" | "denied"> {
  if (!(await canUseEmailFallback(challenge, email, store, now))) {
    return "denied";
  }

  const templateVersion = `marketplace-login-${challenge.region.toLowerCase()}-v1`;
  await transport.sendOtp({
    challengeId: challenge.id,
    destination: email,
    templateVersion,
  });
  await store.append({
    challengeId: challenge.id,
    action: "email_fallback_queued",
    channel: "email",
    region: challenge.region,
    templateVersion,
    recordedAt: now.toISOString(),
  });
  return "queued";
}

async function ingestDeliveryEvent(
  challenge: Challenge,
  event: TransportEvent,
  store: EvidenceStore,
): Promise<void> {
  if (await store.hasSourceEvent(event.providerEventId)) return;

  if (event.destination.channel === "email" && event.kind === "bounced") {
    await store.suppress(event.destination, "delivery_bounce");
  }

  await store.append({
    challengeId: challenge.id,
    action: `delivery_${event.kind}`,
    channel: event.destination.channel,
    region: challenge.region,
    templateVersion: "recorded-at-send-time",
    sourceEventId: event.providerEventId,
    recordedAt: event.observedAt,
  });
}
Enter fullscreen mode Exit fullscreen mode

In production, the idempotency check and append need one atomic storage operation; the separated interface calls above only make the example readable. The same is true for suppression updates. Put a unique constraint on the external event ID, commit the normalized event and suppression transition together, and acknowledge inbound events only after that commit.

The poller can ask listAfter(cursor, limit) and retain the highest returned sequence. A zero-result response is normal. Cap the batch size, authorize by operator role, and report lag from event observation to persistence. Those three details are worth more than a clever streaming layer at beginner scale.

Retry limits, operational growth, and where this plan stops fitting

I would keep the domain contract and replace only pressure points. Higher event volume can move ingestion through a durable queue. Multiple workers can claim partitions while the evidence database preserves uniqueness by source event ID. A large support team may need pushed updates instead of polling, but the push stream should still read normalized evidence rather than raw provider payloads. More transport vendors may justify adapter contract tests using recorded, redacted fixtures.

The catch is that SMS-primary is not suitable for every threat model or every user population. If phishing resistance is the deciding requirement, use a stronger authentication method and treat messaging as recovery or notification rather than the main factor. Stick with email-first when users reliably have verified email but cannot reliably receive SMS, subject to the security assessment for the product. Do not add automatic cross-channel fallback when the risk team cannot explain how an attacker is prevented from steering delivery.

Polling also has a boundary. It works for a small internal console where a short visibility delay is acceptable; it is a poor fit for a high-volume automated consumer that requires immediate event processing. In that case, consume from the durable event path directly and reserve polling for inspection and recovery.

There is a business trade-off too. A single adapter is faster to ship and easier for one person to operate, while multiple transports reduce dependency concentration but multiply template reviews, event mappings, credentials, and on-call paths. I would add the second transport only after a written failure requirement justifies that weekly maintenance load. Outsource the undifferentiated delivery network, but never outsource the decision record that explains who was contacted and why.

References

Top comments (0)