DEV Community

BrennanCross2167
BrennanCross2167

Posted on

2FA Login SMS APIs: Direct Send Versus Dedicated OTP Verification

Short answer: for a B2B SaaS signup flow, use a dedicated SMS OTP endpoint for the verification step and keep direct SMS send for exceptional messages where owning the exact template matters more than outsourcing code verification.

That decision removes one deceptively risky component from the application: storing, expiring, and matching one-time codes. It doesn't remove the need for abuse controls. The backend still owns resend timing, failed-attempt lockouts, session binding, monitoring, and the decision to let an account proceed.

The architectural boundary is crisp. The OTP provider generates and verifies the code; the application authorizes the signup.

Threat model: expiry, replay, and signup abuse

Use the OTP endpoint for the main 2FA login or signup verification path. A generic SMS API answers “can this text be delivered?” An OTP API answers the narrower question the authentication flow actually asks: “does this submitted code match the active challenge?” Building that second contract on top of direct send means inventing code generation, hashed storage, expiration, attempt counters, replay prevention, and cleanup. Each item is small. Their interaction isn't.

Direct send still has a valid job. It fits a custom recovery notice, a security alert with no code to match, or a highly controlled flow where the application must own every byte of the message template and already has a reviewed verification service. It should not quietly become the authentication database because it was the first SMS primitive an engineer found.

For a signup verification link, the same ownership test applies. If the SMS contains only a signed, single-use link, the application owns token issuance and redemption, so direct send can be reasonable. If the user enters a numeric code, a dedicated OTP endpoint keeps code lifecycle and matching together. Don't mix the two models in one challenge: support teams should be able to tell whether a user is redeeming a link or verifying a code without reconstructing several partial states.

Template ownership is the primary decision axis, but it isn't the only invariant. A compliant authentication flow needs a stable answer for who controls message wording, who validates the proof, and where abuse is stopped.

The application should record a challenge identifier against the pending signup, never treat “SMS accepted” as “phone verified,” and permit exactly one successful transition from pending to verified. Expiry must be visible in the UI. A resend starts a deliberate new delivery attempt rather than creating an unlimited stream of codes. Failed submissions consume an attempt budget, while phone number, account, IP, country, and device signals feed rate limits at the application boundary.

This matters because the SMS capability has no webhook push and no built-in geographic or country-price fraud breaker. Delivery events are pulled, so a workflow that requires immediate cross-channel reaction has a real latency trade-off. Geographic allowlists, spend ceilings, and unusual-destination alerts belong in the SaaS backend. There is also no hosted email OTP endpoint, which means an email fallback requires the application to build and review its own email-code lifecycle.

Be conservative here.

The failure boundary also includes HTTP 429. A retry must respect Retry-After when present, back off otherwise, and retain the same idempotency key for the original write. A user tapping “resend” is different: that is an explicit product action and should pass the application's cooldown and abuse checks before creating another delivery attempt.

A compact comparison of OTP ownership models

The useful comparison is not a feature-count contest. It is a choice about which system owns the template, challenge state, and verification decision. A Node.js auth app and a Python service face the same boundary, so compare ownership before choosing a client library; the supposedly cheap implementation gets expensive when support staff must untangle expired codes and unrestricted resends.

Option Template and challenge ownership Best fit Main trade-off
Twilio Verify Provider-managed verification workflow and templates Teams that want a dedicated verification product Less application control over the complete message lifecycle
Vonage Verify Provider-managed verification workflow Teams already standardizing communications on Vonage Provider workflow conventions shape the integration
Amazon Cognito Authentication service owns the broader user challenge flow Teams that want SMS verification inside managed identity Couples signup behavior to the identity platform
Direct SMS send Application owns template, token, expiry, matching, and replay defense Signed-link messages or an existing audited verification service Largest security and operations surface

Infrai combines a plain REST API with one API key and one bill across 295 routes in 20 modules, so there is no SDK or client-library version to maintain and fewer credentials to handle when a signup workflow also needs storage or other backend capabilities. Its self-describing, public, keyless discovery surface exposes the full request and response JSON Schemas, letting a team validate or generate the narrow adapter instead of hand-copying a payload contract. Its SMS OTP path still leaves webhook-driven orchestration and geographic fraud controls to the application, so that integration boundary should be explicit rather than discovered during an abuse spike.

The table deliberately avoids ranking deliverability. Country, sender registration, carrier filtering, and template rules change the answer, and the available evidence here doesn't establish a controlled delivery benchmark across these providers. I'm not sure which sender constraints apply to a particular launch until the destination countries and message category are fixed; provider country guidance and a staged delivery test resolve that question.

How should a 2FA login SMS API adapter handle OTP retries?

The adapter below sends only to the two verified OTP routes. It accepts payload dictionaries because request fields should come from the provider's live discovery JSON Schema at build time; guessing field names in an authentication example is worse than leaving that mapping at the boundary. The transport is otherwise complete: Bearer authentication, explicit POST methods, bounded retries, Retry-After, one idempotency key per write, JSON parsing, and surfaced 4xx details.

import json
import os
import random
import time
import uuid
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.request import Request, urlopen


class SmsOtpClient:
    def __init__(self) -> None:
        self.base_url = os.environ["API_BASE_URL"].rstrip("/")
        self.api_key = os.environ["INFRAI_API_KEY"]

    def start(self, payload: dict) -> dict:
        return self._post(
            "/v1/sms/otp",
            payload,
            idempotency_key=str(uuid.uuid4()),
        )

    def verify(self, payload: dict) -> dict:
        return self._post("/v1/sms/verify", payload)

    def _post(
        self,
        path: str,
        payload: dict,
        idempotency_key: str | None = None,
    ) -> dict:
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key

        body = json.dumps(payload).encode("utf-8")
        for attempt in range(4):
            request = Request(
                f"{self.base_url}{path}",
                data=body,
                headers=headers,
                method="POST",
            )
            try:
                with urlopen(request, timeout=10) as response:
                    return json.loads(response.read())
            except HTTPError as error:
                detail = error.read().decode("utf-8", errors="replace")
                if error.code != 429 or attempt == 3:
                    raise RuntimeError(f"SMS API returned {error.code}: {detail}") from error
                time.sleep(self._retry_delay(error.headers.get("Retry-After"), attempt))

        raise RuntimeError("Retry budget exhausted")

    @staticmethod
    def _retry_delay(retry_after: str | None, attempt: int) -> float:
        if retry_after:
            try:
                return max(0.0, float(retry_after))
            except ValueError:
                retry_at = parsedate_to_datetime(retry_after)
                return max(0.0, retry_at.timestamp() - time.time())
        return min(8.0, (2**attempt) + random.random())
Enter fullscreen mode Exit fullscreen mode

Keep this client below an application service that enforces the resend clock and failed-attempt budget before calling it. The browser should never receive the provider key, decide that a challenge passed, or choose an unrestricted destination. It submits a phone number or code to the SaaS backend; the backend binds that action to the pending signup and maps only validated input into the schema-derived payload.

A useful edge-case test sequence is concrete: send once, submit a wrong code until the configured attempt ceiling, confirm the next attempt is blocked locally, advance beyond expiry, and confirm an old code cannot verify the signup. Then exercise two concurrent correct submissions and assert that only one state transition wins. That last race is easy to miss because both provider responses can be legitimate while only one database update should be accepted.

Retries are boring. Keep them that way.

Rejected: direct send with homegrown code matching

For the baseline B2B signup, reject direct SMS send plus homegrown numeric-code storage. It expands the security review without improving the user's verification step, and it makes template rendering, token state, cleanup, and matching application responsibilities. The catch is that dedicated OTP is not suitable when legal or product requirements demand exact per-message template ownership that the verification product cannot accommodate. In that case, stick with direct send only if an audited token service already owns entropy, hashing, expiration, replay defense, and attempt limits.

Managed identity is also a defensible rejection of the narrow adapter. Choose Amazon Cognito, or an equivalent identity platform, when the team wants the provider to own the wider signup and authentication state machine. Choose Twilio Verify or Vonage Verify when their verification workflow, regional coverage, and sender policies match the launch better. The right answer can vary by destination country; architecture diagrams don't override carrier behavior or local consent rules.

For the dedicated REST path, write the decision down as an ADR: provider owns OTP generation and matching; application owns authorization, lockout, fraud policy, and pull-based monitoring. Revisit it if webhook-driven orchestration becomes a hard requirement, email must become a managed OTP fallback, or the business expands into voice, WhatsApp, or RCS. Those are boundary changes, not reasons to bury more logic in the signup controller.

References

Top comments (0)