DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

Cheap Beginner OTP 2FA Login Architecture: SMS Primary, Email Fallback, Node.js Polling

SMS is a reasonable primary channel for a beginner OTP login, with email as a deliberate fallback and a small polling worker for delivery events. The important design choice is template ownership: keep message templates in your application, version them, and make the delivery adapters dumb. That keeps the login policy, copy, and audit trail under one team's control.

Short answer: use one OTP service boundary behind your Node.js app, store only a digest of each code, send SMS first, offer email after a bounded delay or an explicit user choice, and reconcile delivery with idempotent event polling. Keep the channel switch visible to the user. Do not silently send the same code through every channel.

Choice Good default Main trade-off
Code ownership Application owns the code and templates More responsibility for expiry, abuse controls, and copy changes
Primary channel SMS Familiar, but delivery and cost vary by country and carrier
Fallback Email with the same login attempt Useful when SMS is unavailable, but email account security becomes part of the trust model
Event handling Poll a provider event API from a worker Easy to operate, less immediate than a webhook
Region strategy Keep user, queue, and logs in the intended US or EU region Regional deployment adds routing and data-retention decisions

That is the shape I would start with for a marketplace contact form or account login: small interfaces, explicit state, and no configuration maze. Cheap matters, but a cheap design that cannot explain why a code was sent is expensive to debug.

Template ownership as a rollout gate

Template ownership is the decision axis because copy is part of the security surface. Store templates with an owner, locale, channel, version, and approval state. Render a fixed template version into the delivery record before sending. Do not reconstruct an old message from today's template when investigating a login.

Keep it boring.

For SMS, count the encoded message length before sending. GSM-7 and UCS-2 use different character limits and can split a message into multiple segments; a curly quote or an emoji can change the encoding and therefore the segment count. The safest beginner rule is plain ASCII copy, a short service name, the expiry instruction, and the code. The relevant SMS character and segmentation guidance is in the References section.

For email, sender authentication and reputation are operational requirements. Configure the domain records and follow the sender guidance for the recipient ecosystem. A fallback that lands in spam is a policy failure from the user's point of view, even when the mail request was accepted. The sender guidelines in the References section are a useful baseline for sending-domain and message behavior.

The contact form scenario makes the ownership boundary concrete. A visitor may submit a support request, but the system should not route it to a queue until the identity check succeeds. The OTP record can carry a queueKey such as seller-risk or buyer-billing, while the notification template receives only the data it needs. That prevents a template change from changing routing policy by accident. It also gives the support team a reviewable artifact: the exact copy, locale, channel, and queue decision that existed for that attempt. When a copy editor changes “sign in” to “confirm,” the authentication policy remains untouched, and when a queue is renamed, old delivery records still explain what the user saw. That separation is worth more than another layer of provider configuration.

A message is a delivery artifact, not an authentication decision.

What belongs in an OTP attempt record?

Start with the security state machine, not the SMS SDK. A login attempt needs an opaque identifier, a destination channel, a code digest, an expiry time, an attempt counter, and a status. A useful status set is pending, sent, verified, expired, and locked. The exact storage engine is less important than making transitions atomic.

Generate a cryptographically secure random code. Never store the clear code in a database, queue, analytics payload, or ordinary application log. Compare a digest of the submitted code with the stored digest, enforce a short lifetime, and invalidate the attempt after successful verification. Rate-limit both sending and guessing. The limits should be attached to more than an IP address: account, destination, device, and marketplace action are all useful dimensions.

How does Node.js code consume SMS and email events?

Use three layers. The policy layer decides when a code may be issued and which fallback is allowed. The delivery layer turns a rendered message into a provider request. The event layer reconciles asynchronous status into your own attempt record. Each layer should have a narrow TypeScript interface.

type Channel = "sms" | "email";
type DeliveryState = "pending" | "sent" | "delivered" | "failed";

type OtpAttempt = {
  id: string;
  userId: string;
  channel: Channel;
  codeDigest: string;
  expiresAt: Date;
  status: "pending" | "verified" | "expired" | "locked";
  deliveryState: DeliveryState;
  eventCursor: string | null;
};

type DeliveryRequest = {
  attemptId: string;
  to: string;
  body: string;
  idempotencyKey: string;
};

interface ChannelAdapter {
  send(request: DeliveryRequest): Promise<{ messageId: string }>;
}

interface EventReader {
  listSince(cursor: string | null): Promise<{
    events: Array<{ messageId: string; state: DeliveryState }>;
    nextCursor: string | null;
  }>;
}
Enter fullscreen mode Exit fullscreen mode

The send path writes the attempt before calling the adapter. It uses an idempotency key derived from the attempt ID, so a retry cannot create an untraceable second send. Persist the returned message ID, then let the worker update delivery state. A worker restart should resume from eventCursor, not begin at the beginning of time.

Polling is intentionally boring. Fetch a bounded page, apply only events newer than the stored cursor, commit the cursor and state in one transaction, then wait. If the event API returns a duplicate, the transition is harmless. If events arrive out of order, do not move a terminal state backward. Keep the raw event identifier and a compact normalized state; the former is for diagnosis, the latter is for product logic.

Email is a fallback channel, not a second primary path. The fallback policy should answer three questions: how long to wait before offering it, whether the user can request it immediately, and whether a different code is issued. I prefer a new attempt with a new code when the user explicitly switches channels. It makes the audit trail and revocation rules easier to reason about.

How should the worker expose delivery failures?

The common failure modes are predictable:

  • A retry creates two valid codes. Make one attempt authoritative and invalidate older attempts for the same login action.
  • The UI says “sent” when the provider only accepted a request. Use separate accepted, delivered, and failed meanings in logs and product copy.
  • A worker polls forever after a terminal event. Stop advancing a terminal attempt and retain the final event ID.
  • A support template includes the wrong queue name. Keep queue routing outside message rendering and test the rendered output against a fixture.
  • US and EU traffic share a queue by accident. Make region an explicit property of the user and attempt, then enforce it at the queue and storage boundaries.

I benchmark the send path and the poller separately. A six-digit code does not need a complicated service mesh; it does need measurable latency, retry counts, delivery-state age, verification success, and fallback rate. Those metrics tell you whether the problem is policy, rendering, or delivery.

Where does regional event data live in US and EU deployments?

Treat polling as a distributed systems problem in miniature. Use a lease or another single-owner mechanism per event partition. Bound each request with a timeout. Back off after an empty page and after a transient transport failure, while keeping the cursor unchanged until the page is committed. A queue can carry work between the login API and the worker, but it should not be the source of truth for verification.

The region field should be selected before a delivery request is created. It can drive the adapter configuration, queue placement, log sink, and retention policy. Do not infer region from a browser header after the message is already queued. The exact legal and retention requirements depend on the product and users; I'm not sure one universal US/EU policy exists, so have the owner of that data policy define the allowed locations before launch.

For observability, record attempt ID, channel, template version, region, provider message ID, timestamps, and normalized state. Redact destination addresses and never log the OTP. Give operators a way to answer “why did this user receive this message?” without giving them the secret needed to authenticate.

Testing should cover transitions, not just a happy-path integration test. Use a fake adapter that returns a stable message ID, duplicate events, out-of-order events, and a page boundary. Test that a second send with the same idempotency key is not treated as a new attempt. Test SMS and email rendering independently, including non-ASCII input in a user name so the SMS path cannot accidentally grow without notice.

When is SMS primary the wrong choice?

The catch is that this beginner architecture optimizes for a familiar login flow, not maximum assurance or maximum control. It is not suitable when the account protects high-value actions that require a phishing-resistant authenticator, when the audience cannot reliably receive SMS, or when regional data rules prohibit the chosen delivery arrangement. In those cases, use a stronger authenticator or a channel and deployment model approved for the actual risk.

Stick with email as the primary channel when users already have a well-protected, verified mailbox and SMS coverage is poor. Choose event webhooks instead of polling when near-real-time delivery UX is a hard requirement and the delivery system provides a trustworthy signature and retry model. Keep polling when operational simplicity, replayability, and a small team matter more than instant status updates.

The design should also change as volume grows. A single worker and a relational attempt table are a good beginner baseline; they are not a promise that every future traffic shape should use the same machinery. Revisit partitioning, regional isolation, abuse detection, and template review when the marketplace or threat model changes.

The decision rule is simple: own the authentication state and templates in the application, isolate channel delivery behind interfaces, and make every asynchronous update replayable. That gives a beginner a short path to a first call without hiding the failure modes that will matter later.

References

Top comments (0)