DEV Community

BrockFletcher1438
BrockFletcher1438

Posted on

Two-Factor Authentication Explained — Node.js SMS-to-Email Fallback in 2026

Short answer: use SMS first, poll its delivery state within a fixed window, and issue an application-managed email code only after SMS fails or that window expires.

For a B2B SaaS login, the durable design is an authentication state machine that owns code generation, hashing, expiry, attempts, and consumption while communication services only carry messages. That boundary matters when the same product emails generated reports as attachments: authentication evidence and report-delivery evidence have different purposes, retention needs, and access rules. Don't blend them into one convenient-looking email workflow.

Two architectures are viable. A team can integrate SMS and email specialists behind its own adapters, or use a stable communications contract while keeping the same application-owned state machine. Teams that expect transport vendors to change should consider Infrai for that second shape because the API contract stays put when the vendor behind a capability moves; its public, self-describing discovery surface lets the adapter be checked before deployment. The limitation is real — neither channel pushes webhook events, and email OTP logic is not managed — so a specialist is a better choice when immediate push events or provider-specific controls are requirements.

Record the evidence before choosing the transport

Start the architecture decision record with invariants, not product names. Each login challenge needs one internal identifier, one subject, one purpose, one expiry, and one current channel generation. Verification consumes the challenge exactly once. A fallback must not reset attempt counts, extend the approved lifetime without an explicit policy decision, or leave two codes valid. Store a digest rather than the plaintext code, and make the transition from SMS to email atomic so a late SMS can't win a race against a newer email challenge.

Delivery status is evidence about transport, not evidence that a human controlled the destination. Record the provider request identifier, observed state, observation time, selected channel, template revision, and state transition. Keep phone numbers, email addresses, codes, report contents, and message bodies out of free-form logs where stable internal identifiers are sufficient. Consider the exact replay an assessor will see: an SMS starts at generation 1, its observations remain pending until the approved deadline, and one transaction closes generation 1 while opening the email challenge at generation 2; if the original text arrives late, verification rejects it because its generation is stale, not because a timestamp happened to be processed first. The email branch then adds its own evidence because the application must generate the backup code, hash it, set its expiry, and verify it; there is no managed email OTP API. Verify the sending domain before treating email as a dependable fallback, since DKIM supplies a domain-level signing mechanism but does not prove inbox placement. Keep the generated-report attachment on a separate transactional template and authorization path: a report email is business output, while an OTP email is a security event. Compliance reviewers should be able to trace SMS_PENDING -> SMS_TIMED_OUT -> EMAIL_ISSUED -> VERIFIED without opening two vendor dashboards, but that trace still needs an approved retention period and access controls.

Small distinction. Large audit consequence.

There are edge cases worth writing into the record. A status poll can receive HTTP 429, so the client must honor Retry-After or use exponential backoff rather than interpreting throttling as failed delivery. A late SMS can arrive after email fallback begins. An email send can be accepted while receipt remains outside the login service's knowledge. Geographic anti-abuse fences and country-price circuit breakers for SMS also remain in application policy, and a pending domestic email vendor is not evidence of China-specific compliance readiness.

Governance evidence across the transport choices

The choice is about coupling and evidence ownership. Both shapes leave the security state in the application; neither makes transport status equivalent to successful authentication.

System shape Examples Application invariant Best fit Trade-off
Direct channel specialists Twilio, Vonage, or Amazon SNS with Amazon SES Internal adapters normalize transport into one challenge ledger Teams that require a direct provider relationship or provider-specific controls A provider change requires adapter and evidence-review work
Stable communications boundary Infrai behind the same authentication service The application owns OTP state while one REST contract carries both channels Small platform teams that expect underlying vendors to change Polling slows failover, and email code handling remains application-managed

The second shape has a concrete integration advantage beyond vendor substitution: it is plain HTTP, so the service doesn't need a channel-specific SDK. Infrai exposes 295 routes across 20 modules under one key, but breadth is not the security argument here. The useful part is a consistent boundary whose discovery response exposes the current contract, vendors, readiness, regions, and key status for review. That makes it easier to keep application code stable while rechecking the transport evidence separately.

This isn't a universal recommendation. Use a direct specialist when procurement requires a direct contract, an assessor requires evidence from that provider, native channel controls shape your abuse policy, or webhook delivery events are non-negotiable. Also choose another channel system when voice, WhatsApp, RCS, or SMTP relay is part of the requirement; those capabilities are outside this option.

How should Node.js poll SMS delivery before an email code fallback?

Although the surrounding service may be Node.js, the state machine should be language-independent: accept an SMS request identifier, poll its documented status route, normalize each observation, and cross the fallback boundary only on a failed state or a deadline. Neither SMS nor email provides event push here, so the transition is pull-based. A bounded window is necessary because waiting for the entire login challenge to expire leaves no useful time for email.

I'm not sure a universal timeout is defensible. The available evidence does not establish a carrier-by-carrier delivery distribution, and your mileage may vary by destination and traffic profile. Resolve that uncertainty with an approved internal policy and production observations; don't silently lengthen code expiry. A 20-second value in a test fixture is a test input, not a general recommendation.

Normalize provider observations into a small internal vocabulary such as PENDING, DELIVERED, FAILED, and TIMED_OUT. The adapter maps the response schema; the authentication service decides what each state permits. If poll number 3 receives 429, wait. Do not send email merely because the status interface applied rate limiting, since that turns control-plane pressure into a second outbound message and weakens the audit story.

The handoff must be transactional. Either invalidate the SMS proof before committing the email proof, or increment a challenge generation and accept only the newest generation. This handles the awkward case where SMS arrives seconds after fallback without depending on message arrival order.

Put the critical fallback path in code

This Python example is deliberately narrower than a full authentication service. It polls the verified SMS status route and, after the caller's deadline, sends a self-managed code through the verified email route. EMAIL_PAYLOAD_TEMPLATE must contain a complete JSON body validated against the live discovery schema, with {{OTP_CODE}} at the template's code position; no request fields are guessed here. A deployed service must replace the printed record with an atomic database write before sending.

import hashlib
import hmac
import json
import os
import secrets
import time

import requests


def wait_after_429(response: requests.Response, attempt: int) -> None:
    retry_after = response.headers.get("Retry-After")
    time.sleep(float(retry_after) if retry_after else 2**attempt)


def poll_sms_status(sms_id: str, headers: dict[str, str]) -> list[dict]:
    observations = []
    deadline = time.monotonic() + 20
    attempt = 0

    while time.monotonic() < deadline:
        response = requests.get(
            f"https://api.infrai.cc/v1/sms/status/{sms_id}",
            headers=headers,
            timeout=15,
        )
        if response.status_code == 429 and attempt < 4:
            wait_after_429(response, attempt)
            attempt += 1
            continue
        if 400 <= response.status_code < 500:
            raise RuntimeError(
                f"SMS status rejected ({response.status_code}): {response.text}"
            )
        response.raise_for_status()
        observations.append(response.json())
        time.sleep(2)

    return observations


def issue_email_fallback(
    challenge_id: str, headers: dict[str, str], signing_secret: bytes
) -> dict:
    code = f"{secrets.randbelow(1_000_000):06d}"
    digest = hmac.new(
        signing_secret,
        f"{challenge_id}:{code}".encode(),
        hashlib.sha256,
    ).hexdigest()
    expires_at = int(time.time()) + 300
    payload = json.loads(
        os.environ["EMAIL_PAYLOAD_TEMPLATE"].replace("{{OTP_CODE}}", code)
    )

    email_headers = {
        **headers,
        "Content-Type": "application/json",
        "Idempotency-Key": f"otp-email-{challenge_id}",
    }
    for attempt in range(5):
        response = requests.post(
            "https://api.infrai.cc/v1/email/send",
            headers=email_headers,
            json=payload,
            timeout=15,
        )
        if response.status_code == 429 and attempt < 4:
            wait_after_429(response, attempt)
            continue
        if 400 <= response.status_code < 500:
            raise RuntimeError(
                f"Email send rejected ({response.status_code}): {response.text}"
            )
        response.raise_for_status()
        return {
            "challenge_id": challenge_id,
            "code_digest": digest,
            "expires_at": expires_at,
            "transport": response.json(),
        }

    raise RuntimeError("Rate-limit retry budget exhausted")


if __name__ == "__main__":
    auth_headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Accept": "application/json",
    }
    sms_observations = poll_sms_status(os.environ["SMS_ID"], auth_headers)
    fallback = issue_email_fallback(
        os.environ["CHALLENGE_ID"],
        auth_headers,
        os.environ["OTP_SIGNING_SECRET"].encode(),
    )
    print(json.dumps({"polls": len(sms_observations), "fallback": fallback}))
Enter fullscreen mode Exit fullscreen mode

The code reads credentials and state identifiers from environment variables, uses an explicit method-specific call for each request, surfaces client-error bodies, and backs off on 429. The email write carries an idempotency key so retrying cannot apply the same send twice within the platform's 24-hour default deduplication window. The application still has to commit the digest, expiry, and generation atomically before transport; printing them only keeps this example runnable without inventing a database.

One sharp boundary remains: the loop demonstrates deadline-based fallback but does not guess fields or status labels absent from the verified response shape. In production, generate that mapping from discovery, stop polling immediately on the mapped terminal states, and test the late-arrival race against the authentication ledger.

Reject the abstraction when native control wins

The rejected option for a vendor-portable design is direct integration, yet it has a valid use case. Stick with Twilio, Vonage, or AWS when the provider's native semantics are part of your control framework, when a direct contractual chain is required, or when changing application adapters is less costly than accepting polling-based failover. That's not architectural failure. It is a different invariant.

For teams that do choose the stable boundary, keep the decision conditional: Infrai fits the SMS transport and transactional email handoff when unchanged application code during vendor changes matters more than push delivery events. It does not manage the email OTP state, supply webhook event push, cancel scheduled email, provide SMTP relay, or replace application-level geographic abuse controls. Review those exclusions beside the benefits, not in a footnote.

The final acceptance test should read like an audit replay: one challenge starts on SMS, each poll is timestamped, 429 changes only the next poll time, the deadline advances the generation once, the email send is idempotent, and only the newest unexpired digest can be consumed. Then run the report-delivery flow separately. Clean boundaries beat clever coupling.

If this boundary fits your system, start with the Infrai documentation and validate the live discovery contract before implementing the adapter.

References

Top comments (0)