DEV Community

JaggerBlack5781
JaggerBlack5781

Posted on

Next.js Node.js 2FA: Auditing Login Codes Across US/EU Phone Routes

Short answer: treat SMS delivery and OTP verification as two separate facts, then let Next.js poll a small Node.js status projection that can never authorize the login by itself.

For an e-commerce compliance notice, the deciding constraint isn't how quickly the spinner disappears. It is whether an operator can later explain which notice version was requested, what the messaging network reported, and which OTP challenge the server accepted. A green delivery label cannot answer all three questions.

This changes the build order. Define the release invariants first, implement the smallest read model second, and test crossed event order before tuning polling intervals. Carrier transport is undifferentiated work; the shop's audit boundary is not. That is where the engineering hours belong when one person still has to ship product work every week.

Start with release invariants, not the send button

The first invariant is blunt: a delivery update must never create a login session. Delivery describes the message path. Verification describes a server decision about a live challenge. Even if both appear as one flow to the shopper, they need separate state and separate transition rules.

The second invariant is about the notice. Record the notice template version with the attempt before sending. A later audit question is rarely just "did an SMS move?" It is also "what did we ask the customer to acknowledge?" The answer should not depend on whichever template happens to be current when support opens the account.

The third invariant is authorization. A random attempt identifier is useful for correlation, but possession of it is not permission to inspect it. The polling read must be tied to the same pre-authentication browser context that created the challenge. It should return no phone number, message body, provider credential, or raw transport payload.

Keep the browser vocabulary deliberately boring:

Field Browser use Server meaning
delivery Explain whether the notice is still moving Latest normalized transport observation
challenge Decide whether to keep accepting input Current authentication state
expiresAt Stop stale UI work Server-enforced challenge deadline
version Ignore an older poll response Monotonic projection revision

"Delivered" still does not mean read, understood, or authenticated. This distinction matters when a compliance team reviews the record, and it matters just as much when a support agent sees a customer who received a notice but typed an expired code. One mutable status field collapses those cases into a story the system cannot defend.

US and EU destinations should use the same application states. The transport adapter can translate external labels, while the audit record retains the source event under the shop's access and retention policy. Don't encode a guess such as "EU is slow" into authentication logic. I'm not sure a static regional timeout would remain accurate across routes and carriers; production observations, grouped with enough volume and appropriate privacy controls, are what would resolve that uncertainty.

The smallest useful release gate is therefore behavioral, not architectural: one live challenge can be consumed once; delivery cannot consume it; an expired challenge cannot create a session; an older response cannot replace a newer browser view; and the notice version remains attached to the attempt. Pass those checks before debating queues or live sockets.

What should a Next.js SMS OTP status example expose to the login screen?

Expose a projection, not an event log. The Node.js side owns event ingestion and state changes; the Next.js screen gets only enough data to render the current attempt. This keeps transport-specific details out of client code and makes a provider change an adapter problem rather than a storefront rewrite.

Here is the complete client contract for the first release:

type LoginAttemptView = {
  attemptId: string;
  delivery: "pending" | "delivered" | "undeliverable" | "unknown";
  challenge: "open" | "verified" | "expired" | "locked";
  expiresAt: string;
  version: number;
};

type PollSettings = {
  attemptId: string;
  signal: AbortSignal;
  onChange: (view: LoginAttemptView) => void;
};

const finalChallengeStates = new Set<LoginAttemptView["challenge"]>([
  "verified",
  "expired",
  "locked",
]);

const pause = (milliseconds: number, signal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, milliseconds);
    signal.addEventListener(
      "abort",
      () => {
        clearTimeout(timer);
        reject(signal.reason);
      },
      { once: true },
    );
  });

async function pollLoginAttempt(settings: PollSettings): Promise<void> {
  let newestVersion = -1;

  for (let read = 0; read < 10; read += 1) {
    const response = await fetch(
      `/api/login-attempts/${encodeURIComponent(settings.attemptId)}`,
      { cache: "no-store", signal: settings.signal },
    );

    if (!response.ok) {
      throw new Error(`Status read failed: ${response.status}`);
    }

    const view = (await response.json()) as LoginAttemptView;

    if (view.version > newestVersion) {
      newestVersion = view.version;
      settings.onChange(view);
    }

    if (
      finalChallengeStates.has(view.challenge) ||
      Date.now() >= Date.parse(view.expiresAt)
    ) {
      return;
    }

    const baseDelay = read < 3 ? 2_000 : 4_000;
    const jitter = Math.floor(Math.random() * 300);
    await pause(baseDelay + jitter, settings.signal);
  }
}
Enter fullscreen mode Exit fullscreen mode

Those values are an example UI budget, not a claim about carrier timing. Ten reads bound browser work. The challenge deadline bounds the security decision. Those are different controls, and the server remains authoritative for expiry even if the shopper's clock is wrong.

The version check covers a mundane race: request 8 can finish before request 7. Without a monotonic projection version, the late response can repaint an older state. That does not require exotic infrastructure to reproduce; add latency to two reads in a test and reverse their completion order. Small bug, ugly support ticket.

No heroics.

Polling is suitable here because the screen is short-lived, the response is tiny, and the client already speaks HTTP. Stop it when the tab unmounts, the challenge becomes terminal, the deadline passes, or the read budget is exhausted. A "check again" action may perform another status read, but it must not send another code. Resend is a separate rate-limited command with a separate idempotency key.

The status endpoint also needs ordinary web controls: authorize the viewer against the attempt, send Cache-Control: private, no-store, and return the same narrow shape for every transport. A 404 can cover both an absent attempt and one the viewer may not inspect. OTP submission belongs on a different command path, where the server compares the submitted code with the current unexpired challenge and consumes it atomically before creating a session.

Make crossed events the deployment test

A happy-path browser test proves almost nothing about delivery reliability. The useful build log is a table of crossed events and the invariant each row protects. Run it with a fake clock and deterministic inputs before the weekly deployment.

Sequence to replay Required result
Same delivery event arrives twice One audit observation affects the projection
Delivery arrives after challenge expiry Delivery may update; challenge stays expired
Correct code is submitted twice At most one submission creates a session
Old poll finishes after a newer poll Browser keeps the higher version
Resend command is retried One idempotent resend decision is recorded
Earlier message reports after a resend Event remains attached to its original attempt

The callback boundary is untrusted input. Authenticate it using the transport's documented mechanism, validate its schema, bind its external message identifier to the right internal attempt, and deduplicate on a stable source event identifier. Then feed it through one transition function. Do not let a callback handler write authentication state directly.

The most revealing test uses two challenges for the same account. Call them revision 41 and revision 42. Deliver the older message late, submit its code after revision 42 exists, then deliver the newer message. The expected outcome is straightforward: transport evidence remains attributable to each attempt, the older code cannot consume the newer challenge, and neither delivery observation creates a session. A 409 from the OTP command is a reasonable application response for a stale challenge if that matches the rest of the API, but the exact response shape matters less than making the database transition atomic and testable.

Audit data needs its own discipline. Keep raw phone numbers and callback bodies out of routine logs. Use an opaque attempt ID for correlation, restrict access to evidence, and make retention a policy setting rather than an accidental consequence of log rotation. Legal meaning, consent requirements, and retention periods vary by jurisdiction, so engineering should expose those choices to the people who own the policy instead of pretending that a transport label settles them.

Operations should watch distributions rather than one reassuring success percentage: age of open challenges, time between send acceptance and terminal delivery observation, share still unknown at expiry, resend frequency, verification outcomes, and lockouts. Region can be a useful dimension for US and EU phone routes, but only when the grouping is permitted and has enough observations to guide an action. An alert should lead somewhere concrete, such as checking callback ingestion or disabling a degraded route through an approved operational control.

This is the long part because it earns the release. A polished countdown timer does not.

Change the machinery only when the evidence asks

At modest traffic, a transactional database, an authenticated callback handler, and bounded polling are enough. They are easy to operate alone, easy to test in one process, and compatible with weekly shipping. Outsource message transport, keep the application boundary, and spend the saved operational time on the audit rules that are specific to the business.

At higher callback volume, preserve LoginAttemptView and move ingestion behind a durable queue. Partition work by attempt ID so related transitions remain ordered, route permanently invalid events to a review path, and build regional analytics outside the authentication transaction path. The client does not need to know that any of this changed.

The catch is that polling is not suitable for a long-running notification dashboard or a product that already depends on continuous server push. Server-sent events can fit one-way updates; an existing bidirectional real-time system may justify WebSockets. Stick with bounded polling for a short login screen unless measured request load, latency requirements, or shared real-time infrastructure changes the revenue-per-hour calculation.

SMS also has a security boundary independent of delivery engineering. It is not suitable where the threat model requires phishing resistance or stronger protection against phone-number takeover. Use a phishing-resistant authentication method for high-risk administrative access, while keeping compliance-notice evidence as its own workflow. A transport receipt cannot upgrade the authenticator.

For the first release, the decision rule stays compact: ship when crossed events preserve the invariants, the browser sees no secrets, and an operator can connect the notice version to the attempt without treating delivery as login proof. Scale after measurement. Not before.

References

Top comments (0)