DEV Community

EthanBrooks1647
EthanBrooks1647

Posted on

Designing a Node/Express OTP State Machine for SMS 2FA Delivery Failures

TL;DR

For a simple SMS 2FA login flow, keep a short-lived attempt record in your backend, let a verification service own the OTP secret, and authorize only after a successful code check. Poll your own attempt state for user experience, ingest delivery updates asynchronously, and handle a failed send with a bounded retry or a previously enrolled fallback factor.

Delivery status is evidence for operations. It is not proof of identity.

I use this design behind Node/Express applications even though the reference code below is Python: the framework route should be thin, and the security boundary belongs in a small service with explicit state transitions. I've spent too many on-call shifts separating carrier behavior from application behavior to let a browser, callback, or sent flag decide a login.

How should a simple backend SMS 2FA login flow poll delivery status and handle failed OTP sends?

My architecture decision is a managed verification workflow plus a local attempt state machine. The verification component generates and checks the code. The application owns the user session, abuse controls, attempt lifecycle, fallback policy, and audit trail. A delivery callback updates the trail; it cannot approve the attempt. A client poll reads the local projection rather than repeatedly querying an external service.

These are the invariants I write into the decision record:

  • One attempt belongs to one user, one pre-login session, and one enrolled destination.
  • The raw OTP never enters application logs, traces, analytics, or durable storage.
  • Only a successful verification check can produce approved.
  • Delivery events are idempotent and cannot reopen a terminal attempt.
  • Retries consume a server-side budget, not a counter controlled by the browser.

The state names should describe what the application actually knows. requested means it asked the verification component to start. dispatch_accepted means that component accepted the work. delivery_unknown, delivery_failed, and delivery_reported are transport observations. approved, denied, and expired describe the verification lifecycle. Don't collapse those into a cheerful success; doing so hides the exact boundary support and security teams need during an incident.

Approach Application owns Delivery-status model Best fit Main trade-off
Managed verification Attempt binding, limits, recovery UX, session issuance Callback or status feed updates local state Small teams and ordinary login flows External dependency and less control over OTP internals
Direct SMS with local OTP Code generation, hashing, expiry, checks, limits, and messaging Messaging receipt updates local state Regulated or unusual flows requiring full lifecycle control More security-sensitive code and operational burden
Non-SMS factor Enrollment, challenge lifecycle, recovery, device UX No carrier delivery state Higher-assurance or poor-coverage populations Migration and recovery are harder

The failure boundary sits after your process accepts the login request. A messaging system may accept dispatch while the carrier or handset remains unreachable. So the initial HTTP response should be generic and stable, and the UI should poll a privacy-safe attempt identifier with backoff. This also avoids disclosing whether a phone number maps to an account.

Clear authority.

The critical path is request, check, then observe

I put a narrow adapter between the login service and whatever verification system is selected. An Express handler can call the same three conceptual operations shown here: start, check, and read local status. The adapter result is deliberately small, which makes provider changes and deterministic tests less painful.

from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from typing import Protocol


class VerificationGateway(Protocol):
    def start(self, destination: str) -> str: ...
    def check(self, external_id: str, submitted_code: str) -> bool: ...


@dataclass(frozen=True)
class Attempt:
    attempt_id: str
    external_id: str
    user_id: str
    session_id: str
    expires_at: datetime
    state: str = "dispatch_accepted"
    delivery_state: str = "delivery_unknown"
    resend_count: int = 0


def start_login(
    gateway: VerificationGateway,
    store,
    attempt_id: str,
    user_id: str,
    session_id: str,
    enrolled_phone: str,
) -> Attempt:
    # Rate limits and enrollment checks run before external dispatch.
    external_id = gateway.start(enrolled_phone)
    attempt = Attempt(
        attempt_id=attempt_id,
        external_id=external_id,
        user_id=user_id,
        session_id=session_id,
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
    )
    store.insert(attempt)
    return attempt


def verify_login(
    gateway: VerificationGateway,
    store,
    attempt_id: str,
    user_id: str,
    session_id: str,
    submitted_code: str,
) -> bool:
    attempt = store.get_for_update(attempt_id)
    now = datetime.now(timezone.utc)

    if attempt.user_id != user_id or attempt.session_id != session_id:
        return False
    if attempt.state in {"approved", "denied", "expired"}:
        return False
    if now >= attempt.expires_at:
        store.save(replace(attempt, state="expired"))
        return False

    approved = gateway.check(attempt.external_id, submitted_code)
    store.save(replace(attempt, state="approved" if approved else "denied"))
    return approved
Enter fullscreen mode Exit fullscreen mode

In production, store.get_for_update() represents a transaction or compare-and-swap, not a casual read. That detail prevents two tabs from approving, expiring, or resending the same attempt concurrently. I also require a unique idempotency key on the start operation: if the client loses the response and retries, the backend returns the existing attempt instead of dispatching another code.

My one memorable configuration footgun took 37 minutes to diagnose. A staging deployment had a valid authentication secret but the wrong region environment variable, while our wrapper reduced the upstream rejection to authentication failed; three of us inspected header construction before comparing the boot-time configuration fingerprint. First we checked whether the secret had rotated, then whether whitespace had entered the deployment value, then whether our HTTP client had dropped the authorization header. All three theories were plausible, and all three were wrong. Comparing a redacted startup snapshot finally exposed the region mismatch. I now log a region and credential fingerprint at startup — never the secret — and exercise one synthetic verification in each environment before promotion. I'm not sure why generic auth errors survive in so many wrappers, but your mileage may vary.

The polling response should expose only coarse UI states such as waiting, retry_available, use_fallback, expired, and complete. Keep carrier codes and account details on the server. Polling every few seconds with increasing delay is enough for a login screen; stop after a terminal state or the local expiry. The verification check remains a separate authenticated action because observing delivery and proving possession are different jobs.

Failed sends need a policy, not an automatic loop

I treat resend as a state transition with a cooldown and a small budget shared across tabs and devices. The transaction first checks destination, account, session, source-network, and device limits; it then closes the old UI expectation and creates the current attempt. This prevents a timeout from becoming two texts and prevents the resend button from becoming an SMS abuse endpoint. Responses stay consistent for known and unknown accounts, following the same anti-enumeration principle OWASP recommends for recovery flows.

Retries are writes.

The user-facing branch is intentionally modest. When delivery remains unknown, keep accepting the code until expiry because late status doesn't invalidate a code. When a permanent delivery failure is reported, offer one controlled retry if policy permits, then show a previously enrolled fallback. Never enroll a new fallback inside the failed challenge. Recovery is a separate, higher-risk workflow with its own notifications and review rules.

Operationally, I chart request acceptance, verification approval, expiry, denial, resend, fallback selection, and delivery outcome as separate measures. I segment by country, carrier when lawfully available, template version, and deployment version, while hashing or tokenizing destinations. The useful alert is a change in a ratio or latency distribution — for example, accepted dispatches rising while approvals fall — rather than a page for each rejected message. Retention should be limited to the period justified by fraud response, support, and legal requirements.

Test the awkward orderings. A callback can arrive before the initial response is persisted, twice after an attempt expires, or after the user has already entered a valid code. A poll can race a resend. A code can be submitted against the wrong session. My integration suite signs callback fixtures exactly as the selected service documents, rejects an invalid signature before mutation, and verifies deduplication by event identifier. The handler should preserve the unmodified request bytes until signature verification; parsing and re-serializing first can change the signed representation.

SMS itself has limits. Phone numbers are reassigned, codes can be phished, roaming fails, and carrier filtering varies by geography. For administrative access, high-value transfers, or users with unreliable mobile coverage, SMS is not suitable as the only strong factor; use a phishing-resistant factor such as a passkey or hardware-backed authenticator and maintain a carefully reviewed recovery path. Compliance also changes by destination and use case, so sender registration, consent, message content, quiet hours, and retention need review before launch. This is where a supposedly tiny feature becomes an operating system.

Why reject a hand-rolled OTP, and when is it valid?

I reject locally generated OTPs for a routine login service because they make the application responsible for secure randomness, hashing, single use, expiry, attempt limits, replay resistance, secret handling, dispatch, and verification. OWASP's guidance calls for cryptographically safe tokens, secure storage, single use, expiry, consistent responses, and protection against excessive requests. A managed verification boundary reduces the amount of security-sensitive application code, though it doesn't remove the need for session binding, rate limits, monitoring, or recovery design.

The catch is loss of control.

A managed workflow may not fit data-residency rules, specialized routing, offline deployments, or an established risk engine that must own every challenge transition. In those cases, a hand-rolled service can be valid, but only when a team is explicitly staffed to design, review, test, and operate that lifecycle. Keep direct messaging behind the same gateway interface so the login domain doesn't absorb transport details.

Email is also a poor automatic substitute when the mailbox already acts as the account's recovery root. It can be a useful notification channel for phone changes or suspicious recovery activity, but it brings a different delivery and compliance system. Sender authentication, reputation, complaint handling, and separation of transactional from bulk traffic matter; Yahoo's sender guidance documents concrete expectations for mail sent to its users. I keep email and SMS telemetry separate even when both feed the same risk dashboard.

Choose the boundary based on assurance, operational ownership, geography, accessibility, and recovery needs. The simple flow stays simple only when every signal has one job: dispatch starts a challenge, delivery status explains transport, code verification proves possession for that attempt, and the backend alone issues the session.

References

Top comments (0)