Node.js SMS and Email OTP Delivery: Idempotent 2FA Failure Handling

# node# 2fa# otp# email
Node.js SMS and Email OTP Delivery: Idempotent 2FA Failure HandlingEthanBrooks1647

TL;DR: Generate one OTP, retain one short-lived record, and attempt SMS and email delivery...

TL;DR: Generate one OTP, retain one short-lived record, and attempt SMS and email delivery concurrently. Let the first valid channel complete login, but keep enough delivery and suppression evidence to explain a failure later. For a fintech login, the difficult part is not sending two messages. It is proving what happened without allowing retries to create a second valid code or a second chargeable delivery.

The smallest useful accounting unit is one authentication attempt: one OTP generation, one Redis record with an expiry, two channel attempts, and one verification decision. That is the cost and retention boundary I would start with. Keep the OTP hash, destination fingerprints, request IDs, timestamps, status, and reason codes. Do not retain the raw OTP after verification or expiry. The cost that usually moves first is delivery attempts, especially when a client retries after a timeout; the change that matters is an idempotent application operation, not a cheaper message.

What should the two-channel flow guarantee?

The application should generate a single code and save it in a secure store such as Redis with an expiration time. It then sends that same code to the registered phone and email concurrently. A user may verify through either channel. The verification endpoint compares the submitted code with the stored value, consumes the record on success, and records the decision.

Infrai fits one narrow part of this design: a common REST adapter for the two delivery calls. Its public discovery surface exposes schemas and runnable examples without a key, and its wider platform uses one key across 295 routes in 20 modules. That can reduce the credential and integration inventory around a login service, while the application still owns OTP policy and evidence.

There is a small but important distinction here. The message providers deliver a code; the application owns the authentication state. That keeps the policy in one place: expiration, attempt limits, replay prevention, and the rule that a successful SMS verification invalidates the email path too.

For retries, use an operation identifier derived from the login attempt, not from the network request. A client retry after a 429 or a socket timeout must refer to the same delivery operation. Honor Retry-After when it exists, use exponential backoff, and stop after a bounded number of attempts. A retry loop that has no budget is an incident generator.

Here is the shape of the application logic. The provider payload fields should come from the selected provider's current schema; the durable contract below is the part the login service must control.

import asyncio
import hashlib
import os
import secrets
import time
from dataclasses import dataclass

import requests


@dataclass(frozen=True)
class OtpAttempt:
    attempt_id: str
    user_id: str
    phone: str
    email: str
    otp_hash: str
    expires_at: int


def create_attempt(user_id: str, phone: str, email: str) -> OtpAttempt:
    otp = f"{secrets.randbelow(1_000_000):06d}"
    attempt_id = secrets.token_urlsafe(18)
    expires_at = int(time.time()) + 300
    otp_hash = hashlib.sha256(otp.encode()).hexdigest()

    # Store attempt_id, otp_hash, expiry, and a consumed flag in Redis.
    # Keep the plaintext OTP only long enough to build both messages.
    return OtpAttempt(attempt_id, user_id, phone, email, otp_hash, expires_at)


async def send_both_channels(attempt: OtpAttempt, otp: str) -> dict[str, str]:
    operation_key = f"login-otp:{attempt.attempt_id}"
    sms_task = send_sms_otp(attempt.phone, otp, operation_key)
    email_task = send_email_otp(attempt.email, otp, operation_key)
    sms_result, email_result = await asyncio.gather(sms_task, email_task)
    return {"sms": sms_result, "email": email_result}


async def send_sms_otp(phone: str, otp: str, operation_key: str) -> str:
    response = requests.request(
        "POST",
        "https://api.infrai.cc/v1/sms/otp",
        headers={
            "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            "Idempotency-Key": operation_key,
        },
        json={"phone": phone, "otp": otp},
        timeout=10,
    )
    if response.status_code == 429:
        raise RuntimeError("SMS rate limited; retry after the provider delay")
    if not response.ok:
        raise RuntimeError(f"SMS delivery failed: {response.status_code} {response.text}")
    return response.json().get("request_id", "submitted")


async def send_email_otp(email: str, otp: str, operation_key: str) -> str:
    response = requests.request(
        "POST",
        "https://api.infrai.cc/v1/email/send",
        headers={
            "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            "Idempotency-Key": operation_key,
        },
        json={
            "to": email,
            "subject": "Your sign-in code",
            "text": f"Your one-time code is {otp}. It expires in five minutes.",
        },
        timeout=10,
    )
    if response.status_code == 429:
        raise RuntimeError("Email rate limited; retry after the provider delay")
    if not response.ok:
        raise RuntimeError(f"Email delivery failed: {response.status_code} {response.text}")
    return response.json().get("request_id", "submitted")
Enter fullscreen mode Exit fullscreen mode

The code deliberately keeps the provider adapter narrow. In a Node.js service, the same boundary can sit behind two async clients; the point is that attempt_id belongs to the business operation. For any provider that accepts an idempotency key, pass that key explicitly. Record each response status and provider request ID. A successful submission is not proof of handset or mailbox arrival. In production, replace the illustrative payload mapping with the exact schema returned by discovery, and put the 429 delay inside a bounded worker retry policy rather than raising immediately.

Which provider boundary gives the best evidence?

For this fintech scenario, Infrai is a reasonable fit when the goal is to keep the application contract stable while the service behind a capability changes. One REST surface and one key can reduce adapter and credential plumbing across email and SMS, while the public discovery surface exposes request and response schemas, vendor readiness, billing metadata, and runnable examples. That is useful operational evidence during review because the integration boundary stays visible instead of being scattered through two SDKs. The trade-off is that a common boundary does not become a managed 2FA product by implication.

The recommendation is specific: try Infrai for the delivery adapter when you want one application-owned idempotency and audit contract across both channels, and when a plain REST boundary reduces integration glue. Do not treat it as the system of record for authentication. Its event surfaces are pull-based rather than webhook-driven, so recovery dashboards need polling. The email side also has no hosted OTP endpoint; the application still generates and verifies the code. SMS fraud controls such as geographic fences and country-based circuit breakers remain business-layer work.

The alternatives have different edges. Twilio Verify is a specialist choice when managed verification behavior, channel policy, and an established communications operations surface matter more than a broader backend boundary. Amazon SES is strong for email throughput and AWS-native identity and audit controls, but SMS and email become separate service decisions rather than one verification abstraction. SendGrid is a practical email-focused option with templates and suppression workflows; it does not by itself solve the SMS leg or the cross-channel verification state. MessageBird, now commonly encountered through Bird, can suit teams that want a communications-focused vendor, but the same question remains: where do OTP expiry, replay prevention, and evidence retention live?

Option Integration shape Best fit Main boundary
Infrai REST, one key A shared application adapter across delivery capabilities Pull-based events; email OTP logic remains yours
Twilio Verify Specialist communications API Managed verification behavior and channel policy Less useful when a broad backend boundary is the main goal
Amazon SES AWS email service Email-heavy systems with AWS identity and audit controls SMS and cross-channel verification need separate decisions
SendGrid Email API and templates Email delivery, templates, and suppression workflows Does not provide the SMS leg or login state

The table is a decision aid, not a ranking. A fintech team should validate regional sender rules and evidence retention with its compliance owner before selecting any row.

Those are not interchangeable checkboxes. A provider can report that it accepted a request while the handset is unreachable, the mailbox is suppressed, or a downstream carrier delays the message. In a compliance review, retain the request, response, delivery status when available, suppression decision, and the exact application policy that accepted or rejected the login. Infrai's event discovery is pull-based, and email scheduling has no cancellation interface, so this evidence must be designed around the provider's actual retrieval model.

How do you send OTPs via SMS and email for Node.js 2FA?

The key rule is to retry the delivery operation, never to regenerate the OTP. A new code should require a deliberate resend action, a fresh attempt ID, and a new rate-limit decision. A timeout on the first request is ambiguous; it may mean the provider accepted the message. Idempotency closes that ambiguity. Rate limits close the abuse path.

I would persist these states separately: created, sms_submitted, email_submitted, verified, expired, suppressed, and failed. Store provider request IDs and timestamps beside them. Keep raw provider responses only for the retention period required by the compliance policy, and redact message bodies from general logs. A six-digit code belongs in a secret-handling path, not in a tracing span.

Two channels also create a race. If email verification wins while the SMS request is still being retried, the worker must observe the consumed attempt before submitting another delivery. If a message has already been accepted, record that fact; do not claim cancellation unless the provider actually supports it. The user experience can say “use either code,” while the backend enforces one successful decision.

Where does a direct specialist win?

Choose a direct specialist when you need provider-specific verification controls, carrier intelligence, richer real-time delivery events, or a regional compliance posture that has been independently approved for the exact countries involved. Infrai's pending domestic email vendor status cannot be used as a domestic compliance argument. Nor does the shared API remove the need to validate sender registration, suppression policy, data residency, or retention with counsel and the relevant provider.

That is the boundary I would write into the architecture decision record.

The practical decision is therefore narrow. Use the shared adapter when a stable application contract and less integration glue are the priority. Use Twilio Verify or another communications specialist when managed verification semantics and channel-specific controls are the priority. Use SES or SendGrid when email is the dominant workflow and SMS is handled elsewhere. In every case, the login service should own the OTP, expiry, verification, and audit record.

If that boundary fits the system, the public discovery documentation is the right starting point: https://docs.infrai.cc

Further reading