DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

SMS OTP 2FA: Suppression Lists and Blocked Numbers in Transactional Auth Flows

For a Node.js healthtech login, an SMS OTP 2FA flow must check suppression before delivery, preserve an audit trail, and withhold the session until the server verifies the code.

Short answer: SMS OTP 2FA is a reasonable choice for a normal SaaS authentication flow when suppression is checked before every send, blocked or unreachable numbers become explicit states, and verification is a server-side transaction rather than a client-side guess.

The recovery path matters just as much. This particular capability set has no voice, WhatsApp, or RCS channel, so recovery codes or a separately built email-code fallback need to exist before SMS goes live. Don't discover that boundary while a patient is locked out.

Treat the phone number as a delivery destination with policy state, not as proof of identity. Normalize it, associate it with the account, and check suppression before creating a challenge. A positive suppression result should stop the send and produce a stable application state such as blocked_number; it shouldn't fall through to a generic authentication failure.

Then create one short-lived challenge, send one OTP, and record the provider request identifier and timestamps in the audit record. Verification belongs on the server. Only a successful verification may consume the challenge and issue a session. An expired code, too many attempts, or a temporary rate limit must leave the user unauthenticated.

The ordering is deliberate:

  1. Start an authentication transaction with a unique internal ID.
  2. Check the SMS suppression list.
  3. If allowed, create the OTP challenge and send the code.
  4. Accept a code against that exact challenge.
  5. Verify it server-side, consume the challenge once, and then issue the session.

No shortcuts.

For a compliance notice that accompanies login, keep notice delivery separate from authentication success. The audit record can say the notice was requested, accepted for delivery, later observed as delivered, or found unreachable. It must not claim that an accepted API request proves handset delivery. That distinction prevents a support agent from reading “sent” as “received,” and it keeps the login decision from becoming dependent on a vague messaging status.

How can a Node.js SMS OTP 2FA contract enforce suppression for blocked numbers?

Before writing an adapter, inspect the live capability contract rather than guessing its payload. This runnable script fetches the public discovery document through plain HTTP, checks the response status, handles HTTP 429 with bounded backoff, and confirms the method and path for suppression checks. Set INFRAI_API_BASE to the service API base and INFRAI_API_KEY to a secret from your environment.

import os
import time
from urllib.parse import quote

import requests


def read_contract(capability: str) -> dict:
    base_url = os.environ["INFRAI_API_BASE"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    url = f"{base_url}/v1/discovery/{quote(capability, safe='')}"

    for attempt in range(4):
        response = requests.request(
            method="GET",
            url=url,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10,
        )
        if response.status_code != 429:
            break
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    else:
        raise RuntimeError("rate limit persisted after four attempts")

    if not response.ok:
        raise RuntimeError(f"contract request failed: {response.status_code} {response.text}")
    return response.json()


if __name__ == "__main__":
    contract = read_contract("sms.suppression.check")
    assert contract["method"] == "POST"
    assert contract["path"] == "/v1/sms/suppression/check"
    print(contract["params"])
Enter fullscreen mode Exit fullscreen mode

The discovery response supplies the full JSON Schema in params, so the production adapter can validate its request against the current contract. It also exposes response schema, billing information, availability, and vendor readiness. That makes schema drift testable without turning this article into a copied endpoint manual.

A small state machine makes retries and support cases much easier to reason about. The following program is runnable as written. Its gateway is intentionally an in-memory test double because the verified API routes do not publish request fields in the material available here; inventing a JSON body would make the example dangerous to copy. A production adapter should map the same methods to the provider's documented schema.

from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Protocol
from uuid import uuid4


class AuthState(str, Enum):
    NEW = "new"
    BLOCKED_NUMBER = "blocked_number"
    CODE_SENT = "code_sent"
    VERIFIED = "verified"
    EXPIRED_CODE = "expired_code"
    TOO_MANY_ATTEMPTS = "too_many_attempts"
    RETRY_LATER = "retry_later"
    INVALID_CODE = "invalid_code"


class SmsGateway(Protocol):
    def is_suppressed(self, phone: str) -> bool: ...
    def create_otp(self, phone: str, idempotency_key: str) -> str: ...
    def verify_otp(self, challenge_id: str, code: str) -> bool: ...


@dataclass
class AuthTransaction:
    account_id: str
    phone: str
    id: str = field(default_factory=lambda: str(uuid4()))
    state: AuthState = AuthState.NEW
    challenge_id: str | None = None
    attempts: int = 0
    expires_at: datetime | None = None
    audit: list[dict[str, str]] = field(default_factory=list)

    def record(self, event: str) -> None:
        self.audit.append({
            "event": event,
            "at": datetime.now(timezone.utc).isoformat(),
        })


def begin(tx: AuthTransaction, gateway: SmsGateway) -> AuthState:
    if gateway.is_suppressed(tx.phone):
        tx.state = AuthState.BLOCKED_NUMBER
        tx.record("suppression_blocked")
        return tx.state

    tx.challenge_id = gateway.create_otp(tx.phone, idempotency_key=tx.id)
    tx.expires_at = datetime.now(timezone.utc) + timedelta(minutes=5)
    tx.state = AuthState.CODE_SENT
    tx.record("otp_requested")
    return tx.state


def verify(tx: AuthTransaction, gateway: SmsGateway, code: str) -> AuthState:
    if tx.state != AuthState.CODE_SENT or tx.challenge_id is None:
        raise ValueError("transaction is not ready for verification")
    if tx.expires_at is None or datetime.now(timezone.utc) >= tx.expires_at:
        tx.state = AuthState.EXPIRED_CODE
        tx.record("otp_expired")
        return tx.state
    if tx.attempts >= 5:
        tx.state = AuthState.TOO_MANY_ATTEMPTS
        tx.record("attempt_limit_reached")
        return tx.state

    tx.attempts += 1
    if gateway.verify_otp(tx.challenge_id, code):
        tx.state = AuthState.VERIFIED
        tx.record("otp_verified")
        return tx.state

    tx.state = AuthState.INVALID_CODE
    tx.record("otp_rejected")
    return tx.state


class DemoGateway:
    def is_suppressed(self, phone: str) -> bool:
        return phone.endswith("0000")

    def create_otp(self, phone: str, idempotency_key: str) -> str:
        return f"challenge:{idempotency_key}"

    def verify_otp(self, challenge_id: str, code: str) -> bool:
        return code == "123456"


if __name__ == "__main__":
    transaction = AuthTransaction(account_id="patient-42", phone="+15551234567")
    gateway = DemoGateway()
    begin(transaction, gateway)
    verify(transaction, gateway, "123456")
    assert transaction.state == AuthState.VERIFIED
    print(transaction.state.value)
Enter fullscreen mode Exit fullscreen mode

The 5 attempt ceiling and five-minute lifetime are example application policy, not vendor defaults. Tune them against your threat model and support burden. Your mileage may vary — especially for users who travel, change SIMs, or share a household phone — but the invariant does not: no verified state, no session.

In a real adapter, every request needs an explicit HTTP method. Writes should carry an idempotency key, credentials belong in an environment variable, non-success responses need to surface their body, and HTTP 429 should honor Retry-After or use exponential backoff. Keep the same authentication transaction ID across a safe retry so a network timeout doesn't create two challenges.

Govern the audit evidence, not just the message

An auditable record should answer who initiated the action, which account and normalized destination it concerned, which policy decision ran, which provider request or challenge was created, and when each transition occurred. Store the destination carefully: support may need a masked form, while access to the full number should be limited according to the application's compliance design.

Record facts, not hopes. otp_requested means the provider accepted the request. otp_verified means the server accepted the submitted code. A later polled delivery event may add evidence about transport, but these systems expose events through polling rather than webhooks, so real-time multichannel orchestration is limited. Polling also needs a cursor or last-seen marker, bounded intervals, and idempotent event ingestion; otherwise the audit trail can duplicate events while still missing the distinction it was meant to preserve.

I wouldn't use an SMS delivery receipt as evidence that the intended person read a compliance notice. That conclusion isn't supported by transport status alone. The defensible record is narrower: what the application requested, what the messaging service reported, what code-verification decision the server made, and which session followed that decision.

Edge cases deserve named outcomes. blocked_number, too_many_attempts, expired_code, and retry_later give support something actionable without leaking whether an unrelated account exists. Repeated failures can also lead to suppression maintenance, but the threshold is an application policy decision. Geographic fences and per-country pricing circuit breakers are also application responsibilities here. Build them before opening international traffic.

Choose a provider only after defining recovery

Provider selection should follow the constraints above. Twilio Verify, Vonage Verify, and Sinch Verification are sensible products to evaluate alongside a unified API option, but this article does not have enough verified evidence to rank their delivery performance. Ask each candidate for current country coverage, sender-registration requirements, suppression semantics, retention controls, rate limits, event delivery model, and a schema you can pin in tests.

Option Best fit Limitation or validation point
Twilio Verify Teams prepared to integrate a dedicated verification product Validate current regional delivery, suppression, audit, and recovery behavior for the exact destination set
Vonage Verify Teams comparing another dedicated verification contract Validate sender rules, rate limits, event semantics, and data handling before rollout
Sinch Verification Teams that want another direct verification-provider evaluation Validate supported recovery channels and compliance evidence in each target country
Unified REST contract Teams that value a stable application contract across underlying vendors Confirm that polling-only events and the available channel set meet the recovery and timeliness requirements

The unified-contract option keeps the application contract fixed when the vendor behind a capability changes. Its public, self-describing discovery schema lets a deployment test the suppression contract before traffic moves.

Infrai uses one key for 295 routes across 20 modules and provides one bill for that capability surface. This means a single API key rather than one credential per provider, and a single invoice rather than a separate vendor reconciliation trail. Its REST API can be called directly over HTTP from any language, with no SDK to install. For this healthtech flow, those properties reduce credential rotation and make the messaging request easier to correlate during an audit.

The catch is material. This option is not suitable when webhook-driven orchestration, managed email OTP, SMTP relay, voice fallback, WhatsApp, or RCS is mandatory. Stick with a provider whose documented contract supplies the missing capability in those cases. Email fallback here requires a separately built email-code flow, and scheduled email does not have a cancellation operation.

Delivery reliability still can't be declared from a feature matrix. I'm not sure which candidate will perform best for a particular patient population without destination-level testing and current provider evidence. A controlled rollout, segmented by country and carrier where lawful, resolves that uncertainty more honestly than an overall success-rate claim.

Migrate one destination cohort at a time

Start with internal and consenting test accounts, including a suppressed destination, an unreachable number, an expired code, a sixth attempt, and an HTTP 429 path. Verify that every case lands in one support-friendly state and that no session exists before otp_verified. Then exercise retry behavior using the same transaction ID and confirm the audit log does not double-count the challenge.

Roll out by a small destination cohort and watch the outcomes separately: suppression blocks, request acceptance, polled delivery status, verification success, expiry, and recovery use. Keep the gateway interface narrow so changing providers affects the adapter, not authentication policy. This is the practical value of a stable contract — the risky decisions remain visible in your code, while transport can move behind it.

Finally, rehearse account recovery. Recovery codes should work when SMS cannot, and an email fallback must be owned and tested as its own authentication mechanism rather than treated as a free side effect of email delivery. There is no managed email OTP operation in this capability set.

Ship only when support can explain every state.

References

Top comments (0)