DEV Community

mT41vB6
mT41vB6

Posted on

A Node.js App Builder Contract for Cross-Border 2FA SMS OTP Support

TL;DR

Short answer: treat the SMS OTP API as transport while your application owns a small, atomic challenge state machine for issue, verify, resend, cancel, and expiry. The provider should transport messages and report outcomes; it should not become the authority on whether a login attempt is still valid. For a US/EU app builder, the decisive tests are idempotency, country-aware policy controls, observable delivery states, and a contract that makes late events harmless.

There isn't one universally best API. A managed verification API can reduce the amount of messaging machinery a team operates, while a generic SMS API gives the application tighter control over challenge state. The right boundary depends on which layer must own cancellation, abuse policy, and audit evidence.

Decision record: invariants and failure boundaries

The decision is to keep OTP authorization state in the login service and place the SMS API behind a narrow transport interface. A challenge has an opaque identifier, a salted code digest, an expiry, an attempt counter, a send generation, and one terminal status: verified, canceled, expired, or locked. The SMS provider gets the rendered message and a correlation identifier. It never gets authority to reopen a terminal challenge.

This boundary matters because resend and cancel are state transitions, not messaging features. Cancel cannot make a code disappear from a handset. It can only guarantee that the server will reject that code from that point forward. Likewise, resend isn't merely another send call: it must define whether the old code remains valid, which generation a delivery receipt belongs to, and what happens when two browser tabs act at nearly the same time.

Keep these invariants explicit:

  • At most one generation is acceptable for a challenge.
  • A terminal challenge never returns to a pending state.
  • Verification consumes the challenge atomically.
  • Resend and cancel accept idempotency keys, so a retry doesn't create a second transition.
  • Rate limits apply to several dimensions: challenge, account, destination, IP range, and country policy.
  • Logs contain challenge and provider message identifiers, but never the OTP itself.

The failure boundary follows from those rules. A timeout after a send request is ambiguous — the message might already be moving through a carrier. Retrying blindly can produce duplicate texts. The application should reuse the same idempotency key, record a new send generation only once, and treat a late receipt as telemetry rather than permission to change login state.

No magic here.

Country handling belongs in policy, not scattered conditionals. US and EU traffic can have different sender, consent, retention, and throughput constraints, and those constraints can also vary within those broad regions. I'm not sure which carrier behavior a new traffic mix will encounter; a country-by-country canary, backed by delivery and verification measurements, resolves more than a broad regional label does. Compliance review should approve the policy data and message templates before rollout, while the state machine stays the same.

How should 2FA login support handle SMS OTP races?

Expose commands rather than writable status fields: create a challenge, verify a code, resend a challenge, and cancel a challenge. Each command checks the stored version and commits exactly one transition. The Node.js HTTP layer can map those commands to endpoints, but the model below is deliberately transport-neutral and shown in Python so the transition rules are easy to inspect.

from dataclasses import dataclass, replace
from datetime import datetime, timezone
from enum import Enum


class Status(str, Enum):
    PENDING = "pending"
    VERIFIED = "verified"
    CANCELED = "canceled"
    EXPIRED = "expired"
    LOCKED = "locked"


@dataclass(frozen=True)
class Challenge:
    challenge_id: str
    code_digest: str
    expires_at: datetime
    generation: int = 1
    attempts: int = 0
    status: Status = Status.PENDING
    version: int = 1


def cancel(challenge: Challenge, now: datetime) -> Challenge:
    if challenge.status == Status.CANCELED:
        return challenge
    if challenge.status != Status.PENDING:
        raise ValueError(f"terminal challenge: {challenge.status}")
    if now >= challenge.expires_at:
        return replace(challenge, status=Status.EXPIRED, version=challenge.version + 1)
    return replace(challenge, status=Status.CANCELED, version=challenge.version + 1)


def resend(challenge: Challenge, new_digest: str, now: datetime) -> Challenge:
    if challenge.status != Status.PENDING:
        raise ValueError(f"terminal challenge: {challenge.status}")
    if now >= challenge.expires_at:
        return replace(challenge, status=Status.EXPIRED, version=challenge.version + 1)
    return replace(
        challenge,
        code_digest=new_digest,
        generation=challenge.generation + 1,
        version=challenge.version + 1,
    )
Enter fullscreen mode Exit fullscreen mode

The repository update must use compare-and-swap on version; the pure functions alone don't prevent two processes from committing generation 2. A practical HTTP contract returns the existing result when an idempotency key is replayed. I'd use 409 for a stale version or an impossible terminal transition and 429 for an exhausted policy budget, with a machine-readable reason that distinguishes destination, account, and challenge limits. Those are application contract choices, not claims about a provider.

Code verification needs the same care. Hash the submitted code, compare it without leaking timing differences, increment the attempt count atomically, and lock the challenge when its configured budget is exhausted. Don't report whether the account, phone number, or code was the failing element. That detail is useful to attackers and rarely useful to a legitimate user.

A delayed receipt may name generation 1 after generation 2 has been issued. Store it against generation 1. Do not change the current digest, extend expiry, or mark the challenge usable. This is the edge case that separates delivery observability from authorization state.

Comparing the API ownership models

The shortlist should be tested with the same contract suite and representative destination countries. Marketing feature lists won't expose ambiguous timeouts, callback reordering, or cancellation semantics.

Model Application owns External service owns Strong fit The catch
Managed verification API Session binding, UI, local risk decisions Code generation, send workflow, provider-side attempt policy Small teams that accept the service's challenge lifecycle Not suitable when cancel, resend, or audit semantics must match a custom state machine exactly
Generic SMS API plus local OTP state Full challenge lifecycle and abuse controls Message submission and delivery events Teams needing consistent rules across channels or providers More security-sensitive code, operational policy, and testing remain in the app
Direct carrier or aggregator integration Challenge lifecycle, routing, sender policy, failover Network handoff High-volume teams with telecom operations expertise Usually too much routing and compliance work for an app team
Email fallback Separate email challenge and deliverability controls Email transport Recovery paths where the address is already trusted Email authentication and spam filtering are a different operational system; DKIM does not authenticate SMS

For each candidate, test five scenarios: retry the same send after a client timeout, race resend against verify, race cancel against verify, deliver callbacks out of order, and exhaust limits from multiple IP addresses against one destination. Record the final challenge state, number of accepted codes, number of transport submissions, and audit events. The expected accepted-code count is never greater than one.

Delivery rate by itself is a weak selection metric because a received message can arrive after the challenge is canceled or replaced. Track time to verified login, duplicate submission rate, resend frequency, late-generation receipts, lockouts, and user abandonment by destination country. Cost still matters, but compare the whole verified-login path — including fraud controls and operational work — rather than a per-message number in isolation.

An app builder also needs an exportable event trail. Correlation should flow from login request to challenge generation to transport submission and receipt. Redact destinations in general logs, restrict access to any reversible contact data, and define retention with the privacy and security owners. If an API cannot preserve your correlation identifier or provide enough status to reconcile an ambiguous request, the missing observability becomes application risk.

Rejected option, and when it is still valid

The rejected design makes the messaging provider's message identifier the OTP session identifier and implements resend by creating another unrelated message. Cancellation then becomes a local deny-list entry layered over several provider objects. Races are hard to reason about because the login service has no single versioned record to arbitrate them, and switching transport changes authorization behavior.

Still, don't build a custom challenge engine by reflex. Stick with a managed verification flow when its documented resend, expiry, attempt, and cancellation behavior matches the product requirements and the team doesn't want to own security-sensitive code. Its fixed lifecycle can be a useful constraint. The trade-off is reduced control, not inferior engineering.

A generic SMS layer is the opposite choice. Use it when the organization already has a reviewed authentication state machine, needs the same cancellation semantics across SMS and another channel, or must keep authorization independent of transport. The price is ongoing threat modeling, concurrency tests, on-call dashboards, policy maintenance, and compliance review. Your mileage may vary — team capability is part of the architecture.

Before signing a contract, write the state transitions and expected race outcomes in plain language. Then make every candidate pass them in a sandbox and a limited production canary. The best API is the one that fits the chosen ownership boundary without weakening the invariants.

References

DKIM is relevant only to the email fallback boundary described above; it is not an SMS security mechanism.

Further reading

The tool-definition guide is useful here as an interface-design analogy: commands need precise descriptions and schemas. It does not define OTP or telecom behavior.

Top comments (0)