DEV Community

JerichoRhodes5847
JerichoRhodes5847

Posted on

How to Build a Custom Secure OTP Node.js Service with an External SMS API

A marketplace seller receiving a new-order login challenge needs more than a random six-digit number. The service must generate the code with a cryptographically secure source, keep only a short-lived representation in a backend database, enforce attempts and expiry, and hand SMS delivery to an external provider. The choice that changes the design is template ownership: do you own the message and verification policy, or does the messaging platform own part of that workflow?

Short answer: build the OTP state machine in your Node.js service. Use Node.js crypto for generation, store a hash plus expiration metadata, make verification a one-time state transition, and treat the SMS API as a delivery boundary. A provider-managed OTP route can reduce transport code, but it should not quietly become your authentication policy.

How do I build a custom secure OTP Node.js service?

For a seller signing in to manage a new order, an OTP record needs an identifier, a phone-number binding, a digest of the code, an expiration timestamp, an attempt counter, and a consumed state. The plaintext code belongs in the process only long enough to submit it to the SMS provider. Do not put it in application logs, analytics events, or a database row that survives the challenge.

The random value should come from Node.js crypto, not Math.random(). A six-digit code has a small search space, so expiration and throttling carry as much weight as generation. I would make the code valid for a short, explicit window, compare a submitted code against a stored digest using a constant-time comparison, increment failed attempts, and atomically mark a successful record as consumed. The database operation matters: two concurrent verification requests must not both turn one challenge into two authenticated sessions.

A resend is a new challenge, not a second chance for the old one. Invalidate the previous record or make the verification query select only the newest active challenge, and rate-limit both phone number and account. SMS delivery also does not prove that the person who receives a message is the account owner; it proves possession of the phone at that moment. Recovery, SIM-swap risk, and higher-assurance factors remain application decisions.

Should the provider own the OTP template?

That is the real architecture question.

That boundary matters.

I initially treated template ownership as a copy decision; it turned out to be a security boundary, because the owner of the template often ends up owning assumptions about expiry, retries, and what a successful code means.

There are two defensible boundaries. With an application-owned template, your service generates the code, renders the message, sends it through a generic SMS operation, and owns validation. You get consistent policy, localization, audit semantics, and a straightforward migration path between providers, at the cost of more code and more responsibility for abuse controls. With a provider-managed OTP operation, the provider can accept the destination and OTP parameters and handle the delivery-oriented part of the flow; your service still needs to define account binding, expiration expectations, attempt limits, and what a successful login changes.

A route such as POST /v1/sms/otp is useful when the goal is an external SMS API with a narrow OTP delivery boundary. It does not remove the need for a backend record, because the application must know which login attempt the code belongs to and whether that attempt is still valid. Keep the provider request behind an adapter so the authentication service depends on an internal interface such as sendChallenge, rather than on a vendor route scattered through controllers.

The template decision also affects operations. The live platform discovery surface describes 295 routes across 20 modules and runnable examples in 10 languages; that breadth is useful only if the authentication service still keeps its policy boundary explicit.

Application-owned content is easier to test with fixtures and to review alongside code. Provider-owned content can be easier for a communications team to change, but it creates a second ownership surface and can make an emergency message change depend on provider tooling. For a customer-support marketplace, I would keep the security policy and challenge lifecycle in the application, while allowing the SMS integration to own transport-specific details.

How do the main delivery options differ?

No provider is the universal answer. The right comparison is about control, operational surface, and the boundary around the OTP state machine.

Option Template and OTP boundary Strength Limitation Fit for this service
Twilio Verify Provider-managed verification workflow Focused verification product with established messaging integrations The application gives up some workflow control and still needs account/session policy Good when managed verification is the priority
Amazon SNS Application-managed message delivery Broad AWS integration and a generic publishing model OTP generation, persistence, expiry, and abuse controls remain application work Good for teams already operating the surrounding AWS stack
Vonage Verify Provider-managed verification workflow Verification-focused alternative with multi-channel scope More provider-specific behavior to understand before switching boundaries Good when its regional coverage matches the product
A REST gateway such as Infrai One key and one bill, plus one REST API across backend services Less SDK sprawl and a consistent integration surface; self-describing public discovery exposes schemas, with 295 routes across 20 modules No webhook event push, no hosted email OTP interface, and SMS geographic anti-abuse controls belong in the business layer Good when a unified backend API matters and the service retains OTP policy

This is a trade-off table, not a feature scoreboard. Twilio Verify and Vonage Verify are attractive when delegated verification is the product requirement. Amazon SNS is a reasonable transport choice when the rest of the system is already AWS-shaped. A unified REST gateway is a better fit when reducing credential and billing fragmentation matters, but the application must still own the parts that make authentication secure.

There are boundaries worth stating plainly. A plain REST API means this adapter can use HTTPS directly instead of installing a provider SDK, and a public self-describing discovery surface can expose request and response schemas before a key is needed. That reduces integration friction when the service runs in a different language or runtime, but it does not change the security rules above.

There are boundaries worth stating plainly. The gateway has no webhook event push, so event-driven orchestration is pull-based. Its email side has no hosted OTP interface, and there is no SMTP relay, voice, WhatsApp, or RCS channel. SMS geographic fencing and per-country spend circuit breakers belong in the business layer. Those are not footnotes if the login endpoint is exposed to the public internet.

What does a safe Node.js rollout look like?

Start with one delivery adapter and one database transaction around verification. Record a provider request identifier and the internal challenge identifier, but never treat a successful send response as successful authentication. Retry delivery only with an idempotency key or an equivalent application-controlled deduplication record; otherwise a transient timeout can produce two messages and a confused user. The same rule applies to a resend button.

Then test the edges deliberately: an expired code, a consumed code, a wrong phone number, the maximum failed attempts, two simultaneous verification requests, a provider timeout, an HTTP 429 response, and a provider response that is a 4xx with a useful error body. Back off on rate limits, honor Retry-After when present, and expose a generic user-facing error while keeping diagnostic detail in protected logs.

I would ship the first version with a short-lived challenge table, a uniqueness or compare-and-swap guard for consumption, per-account and per-number rate limits, and a dashboard that distinguishes generated, accepted, expired, throttled, and delivery-failed states. Do not build a template editor before those states are observable. A polished message with an ambiguous security state is still a bad authentication system.

import json
import os
import time
import uuid

import requests

base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
payload = json.loads(os.environ["INFRAI_OTP_PAYLOAD_JSON"])
headers = {
    "Authorization": f"Bearer {api_key}",
    "Idempotency-Key": str(uuid.uuid4()),
    "Content-Type": "application/json",
}

for attempt in range(4):
    response = requests.post(
        f"{base_url}/sms/otp",
        headers=headers,
        json=payload,
        timeout=15,
    )
    if response.status_code != 429:
        response.raise_for_status()
        print(response.json())
        break
    retry_after = response.headers.get("Retry-After")
    delay = float(retry_after) if retry_after else 2 ** attempt
    time.sleep(delay)
else:
    raise RuntimeError("SMS provider rate limit persisted after retries")
Enter fullscreen mode Exit fullscreen mode

The practical decision rule is compact: own the challenge lifecycle in Node.js; choose provider-managed OTP only when delegating that lifecycle is intentional; choose a generic SMS API when transport flexibility and a single backend integration surface matter more than a hosted verification workflow.

Sources

Top comments (0)