DEV Community

RonanHalewood782
RonanHalewood782

Posted on

Node.js 2FA Login Event Governance for US/EU SMS and Email (Evidence First)

Short answer: for an edtech marketplace seller, make the audit record the source of truth, then let SMS be the primary OTP delivery and email a policy-controlled fallback. Polling should reconcile delivery events; it should never decide identity by itself.

That ordering sounds backwards if your first prototype is a login form. It is useful. A reviewer can ask “why did this seller get access to this order?” months later, after a template, a region rule, and a worker have all changed. The answer must be reconstructable without retaining the learner's name, the OTP, or a complete message body.

Evaluation starts with the evidence ledger, before changing SMS transports

Model one challenge and several delivery attempts. The challenge has an opaque seller reference, an expiry, a consumed timestamp, and a policy version such as seller-login-us-eu-v1. Each attempt records its channel, reason, template ID, locale, and external event ID. Authentication consumes the challenge once; an order-notification job is a separate state machine. That separation matters in a marketplace: a delayed SMS status must not resend an order alert, and an order alert must not prove that a seller passed 2FA.

The policy version is the join key for governance. It says which regional routing, fallback consent, retention period, and template rules were active when the decision happened. Keep the policy immutable after use. A later configuration edit should create v2, not silently rewrite old receipts.

Ship the ledger first.

Picture a support ticket from a seller in France: “I received two codes, then saw the new course order.” The useful investigation is a timeline, not a provider dashboard screenshot. We need to distinguish a user tapping “resend” from a worker retry, a fallback authorized by policy from one caused by a timeout, and a delivery observation from an authorization decision. In one test sequence I intentionally delayed the SMS observation by 90 seconds, inserted an email fallback, replayed the same external event twice, and attempted order access before challenge consumption. The expected result is one consumable challenge, one recorded fallback reason, one deduplicated observation, and no order access on the early attempt. That sequence is longer than the happy path, but it is the path a compliance review actually examines.

I keep the envelope boring because evals are easier to read than provider-shaped logs. Here is a small, runnable check for the invariants I want before wiring a transport.

from dataclasses import dataclass
from datetime import datetime, timezone


@dataclass(frozen=True)
class Event:
    event_id: str
    challenge_id: str
    kind: str
    channel: str | None
    policy_version: str
    observed_at: str


def validate(events: list[Event]) -> list[str]:
    errors: list[str] = []
    ids: set[str] = set()
    consumed = 0
    previous: datetime | None = None

    for event in events:
        if event.event_id in ids:
            errors.append("DUPLICATE_EVENT")
        ids.add(event.event_id)
        current = datetime.fromisoformat(event.observed_at.replace("Z", "+00:00"))
        if previous and current < previous:
            errors.append("TIME_REGRESSION")
        previous = current
        if event.kind == "challenge_consumed":
            consumed += 1

    if consumed > 1:
        errors.append("MULTIPLE_CONSUMPTION")
    if not events:
        errors.append("EMPTY_RECEIPT")
    return errors


events = [
    Event("evt-1", "ch-01", "challenge_created", None,
          "seller-login-us-eu-v1", "2026-08-30T09:00:00Z"),
    Event("evt-2", "ch-01", "delivery_requested", "sms",
          "seller-login-us-eu-v1", "2026-08-30T09:00:01Z"),
    Event("evt-3", "ch-01", "fallback_requested", "email",
          "seller-login-us-eu-v1", "2026-08-30T09:01:30Z"),
    Event("evt-4", "ch-01", "challenge_consumed", None,
          "seller-login-us-eu-v1", "2026-08-30T09:02:04Z"),
]

assert validate(events) == []
assert all(event.policy_version for event in events)
Enter fullscreen mode Exit fullscreen mode

The fixture is not a security proof. In production, protect the event store, restrict who can append, and retain an independently stored chain head or export when the threat model requires tamper evidence. I'm not sure one retention period fits a two-person startup and a regulated school network; make that uncertainty an explicit policy decision.

How should Node.js implement OTP 2FA login with SMS and email fallback?

The Node.js edge creates the challenge and writes challenge_created plus delivery_requested in one transaction. A worker sends the SMS attempt. If the seller asks for recovery and the active policy permits it, another worker appends fallback_requested and sends email against the same challenge. Verification uses a conditional update (consumed_at IS NULL and an unexpired deadline), so two requests cannot consume the code twice.

Polling belongs in a bounded reconciliation loop. Select attempts that are unresolved, lease them for a short interval, ask the transport adapter for a status, and append the normalized observation with its external event ID. A repeated observation is harmless when that ID is unique. Stop polling at a terminal state or the application's deadline. Never infer “delivered” from a missing response, and never let a poll result grant order access.

Two channels do not automatically make two factors. SMS and email both prove control of a communication destination. If policy requires independent factors, add a separate authenticator and describe the assurance honestly in product copy and in the receipt.

For US/EU operation, keep region as a policy attribute rather than scattering if region == ... across workers. Store the rule version, lawful-purpose decision, and destination class. Raw phone numbers, email addresses, order contents, and OTP values stay outside the evidence record; use short-lived references or keyed fingerprints when correlation is necessary.

Implementation details for polling events and challenge state

A compliance reviewer usually needs five answers: which rule selected SMS, who requested fallback, which template and locale were used, what the transport reported, and why access followed. An evidence table can answer those questions while minimizing personal data.

Review question Retain Exclude
Why was SMS primary? policy version, region class, reason code full phone number
Why was email allowed? actor, request time, recovery rule email body, OTP
Which wording shipped? template ID, locale, content hash, encoding class learner and order fields
What did polling observe? attempt ID, observed time, normalized status, external event ID authorization headers
Why was access granted? consumption event, session reference, authorization decision session secret

SMS encoding is an easy source of accidental evidence gaps. GSM-7 and UCS-2 have different per-message capacities; concatenated messages reserve space for segment headers, so a locale change can alter segment count and cost. Record encoding class and segment count before sending, and test the exact templates. Email sender guidance similarly separates domain authentication and message practices from mailbox placement; acceptance by a relay is not proof that a seller read the recovery mail. The standards and guidance are linked below.

No receipt, no claim.

This is also where an eval harness earns its keep. Generate event permutations in Python, assert the invariants, and keep a failed sequence as a regression fixture. I started with only the successful four-event flow; adding the delayed observation exposed that “last status wins” could overwrite a prior terminal state. The fix was a forward-only transition table plus a unique external event ID, not another retry setting. Your mileage may vary if the transport exposes richer event ordering, so document what ordering the adapter can actually guarantee.

Evidence fields that survive a US/EU policy change:

The relational design is a good beginner baseline when the event volume is modest and the team can operate one worker and one poller. It is not suitable when status must be near-real-time but the transport offers only slow lookups, or when unresolved attempts grow beyond a manageable polling budget. Choose an authenticated inbound event stream in that case, with polling retained as a repair path. Stick with polling when you need a simple, inspectable recovery loop and can tolerate bounded delay.

It also does not solve phishing, SIM-swap risk, compromised mailboxes, or a dishonest operator with database access. Add a stronger factor, device binding, or an external audit boundary when those threats matter. Cheap delivery is not the decision axis; evidence quality, recovery semantics, and operational burden are.

My production checklist is deliberately short: version every policy and template, keep challenge and order notification ledgers separate, make consumption idempotent, lease poll work, cap retries, redact payloads, and run generated event sequences through the same Python eval before deployment. The notebook should fail loudly on duplicate consumption, time regressions, fallback without consent, and access before authentication.

Reliability boundaries and the moment to switch designs

The relational design is a good beginner baseline when the event volume is modest and the team can operate one worker and one poller. It is not suitable when status must be near-real-time but the transport offers only slow lookups, or when unresolved attempts grow beyond a manageable polling budget. Choose an authenticated inbound event stream in that case, with polling retained as a repair path. Stick with polling when you need a simple, inspectable recovery loop and can tolerate bounded delay.

It also does not solve phishing, SIM-swap risk, compromised mailboxes, or a dishonest operator with database access. Add a stronger factor, device binding, or an external audit boundary when those threats matter. Cheap delivery is not the decision axis; evidence quality, recovery semantics, and operational burden are.

My production checklist is deliberately short: version every policy and template, keep challenge and order notification ledgers separate, make consumption idempotent, lease poll work, cap retries, redact payloads, and run generated event sequences through the same Python eval before deployment. The notebook should fail loudly on duplicate consumption, time regressions, fallback without consent, and access before authentication.

References

Top comments (0)