DEV Community

EchoF76
EchoF76

Posted on

Node.js SMS OTP Login API: Resend Cooldowns for Healthcare Reminders

Short answer: use managed SMS OTP to send and verify a code, but keep resend cooldowns, attempt counters, expiration, and authenticated session state in your FastAPI application. For a healthcare appointment reminder portal, the clean integration boundary is a small state machine: validate the existing session, permit one send, record the challenge locally, and accept a bounded number of verification attempts.

This is an integration-effort decision, not a claim that SMS is the strongest authenticator. NIST treats PSTN out-of-band authentication as restricted. For a reminder portal where SMS OTP is an accepted second factor, the design below keeps the risky policy decisions visible and testable.

What should a Node.js SMS OTP login API own?

The provider should deliver and check the challenge. The application should decide who may request one, how often, and what successful verification unlocks. That split matters because this SMS surface does not supply geographic fences or per-country spend cutoffs. It also does not push delivery webhooks, so real-time orchestration cannot depend on an event arriving. A common simple implementation checks the cooldown and writes it after the network call. Two requests arriving together can both pass that check, send two codes, and then race to overwrite the same transaction. Reserve the send atomically before making the call, reuse one idempotency key for retries of that reservation, and release or reconcile it according to the final result. This is the kind of edge case a notebook happy path misses and a concurrency eval catches quickly.

Store an opaque transaction ID, next_send_at, expires_at, and attempts_remaining per login. Bind them to the user and intended action. Never let a browser-supplied phone number silently replace the number associated with the appointment account.

Keep it boring.

A starting policy might allow a resend after 60 seconds, expire local state after 10 minutes, and stop after five submissions. Those are example application settings, not provider guarantees or security standards. Put them in configuration, exercise their boundaries in an eval harness, and tune them from abuse and support data.

Can one credential cover identity and SMS?

Yes, if reducing integration work is the main constraint. Infrai puts identity and SMS behind a single key and one bill, and exposes them through one plain REST API with no SDK to install, so any language or runtime can make the same HTTP calls. That replaces separate credentials and invoice reconciliation while keeping provider-specific types out of the login service. The public, self-describing discovery surface exposes full request and response schemas without a key, letting the FastAPI adapter validate payloads at one thin HTTP boundary. The contract can stay in the application while the provider behind a capability changes.

The focused example checks an existing session, then uses that successful response to authorize the OTP send. It accepts exact OTP bodies as JSON configuration because the live discovery schema is the authority for fields; guessed phone or code fields do not belong in production code. Send and verify share one client and key.

import asyncio
import json
import os
import time
import uuid

import httpx

BASE_URL = os.environ["INFRAI_BASE_URL"]
API_KEY = os.environ["INFRAI_API_KEY"]
SEND_BODY = json.loads(os.environ["OTP_SEND_JSON"])
VERIFY_BODY = json.loads(os.environ["OTP_VERIFY_JSON"])


def delay(response: httpx.Response, attempt: int) -> float:
    try:
        return max(0.0, float(response.headers["Retry-After"]))
    except (KeyError, ValueError):
        return min(2 ** attempt, 16)


async def call(client, method, path, body=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    for attempt in range(5):
        response = await client.request(
            method=method, path=path, headers=headers, json=body
        )
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        await asyncio.sleep(delay(response, attempt))
    raise RuntimeError("request remained rate-limited after five attempts")


async def login(session_id: str) -> dict:
    transaction_id = str(uuid.uuid4())
    next_send_at = 0.0
    expires_at = 0.0
    attempts_remaining = 5
    async with httpx.AsyncClient(base_url=BASE_URL, timeout=15.0) as client:
        session = await call(client, "GET", f"/auth/session/verify/{session_id}")
        now = time.time()
        if now < next_send_at:
            raise RuntimeError("resend cooldown is active")
        sent = await call(
            client, "POST", "/sms/otp", SEND_BODY, f"send:{transaction_id}"
        )
        next_send_at = now + 60
        expires_at = now + 600
        if time.time() >= expires_at or attempts_remaining <= 0:
            raise RuntimeError("local OTP transaction is no longer valid")
        attempts_remaining -= 1
        verified = await call(
            client, "POST", "/sms/verify", VERIFY_BODY,
            f"verify:{transaction_id}:1"
        )
        return {"session": session, "sent": sent, "verified": verified}


if __name__ == "__main__":
    print(asyncio.run(login(os.environ["SESSION_ID"])))
Enter fullscreen mode Exit fullscreen mode

Install httpx and set INFRAI_BASE_URL, INFRAI_API_KEY, SESSION_ID, OTP_SEND_JSON, and OTP_VERIFY_JSON. Generate the JSON values from the live discovery schemas. In a real FastAPI service, persist the state transactionally; local variables above only make the handoff readable. Do not log either body.

There is a plain trade-off: one vendor to trust, one bill, and one outage surface. Fewer credentials reduce adapter work, but concentrate the blast radius. Preserve an internal challenge interface and test it with schema-valid fixtures so a provider switch remains an adapter change.

Where do the alternatives fit?

Stack Better fit Integration cost
Auth0 + Twilio Verify Both are already approved Two signups, two credential sets, and glue mapping the Auth0 subject to Twilio's verification lifecycle
Clerk + Twilio Verify Clerk sessions and components are established Two credential sets and explicit synchronization of session and challenge state
Amazon Cognito + Amazon SNS The team already operates AWS IAM and regional controls More AWS policy and service configuration enters the login path
One REST identity/SMS contract Credential count and SDK integration dominate Provider concentration; application-owned abuse controls and polling remain

Twilio Verify is the direct specialist comparison because it starts and checks verifications. Auth0 and Clerk cover broader identity workflows, while Cognito and SNS suit teams already committed to AWS operations. There is no universal winner. Existing security review, regional requirements, recovery paths, and the team's ability to operate each control should decide.

A successful send call is not proof of delivery or ownership. If product behavior depends on progress, poll SMS status or events with a capped interval and deadline. There are no webhook pushes, so aggressive polling adds load without creating real-time orchestration.

Email fallback is a separate implementation. There is no managed email OTP endpoint, so the application must generate, store, expire, and verify that code. There is no SMTP relay; voice, WhatsApp, and RCS are outside the available channels. If one is a launch requirement, pick a stack that supports it.

Keep reminder and challenge copy free of unnecessary appointment details, and have privacy and security reviewers approve storage, retention, vendors, and regions. This design does not establish regulatory compliance.

What should be measured before adopting it?

Start with an eval matrix. Exercise a concurrent double-send, a retry after 429 with Retry-After, the 59/60-second boundary, local expiration, five failed submissions, a stale session, and a timeout after an accepted idempotent write. Confirm that no case creates two usable transactions.

Then measure completion rate, resend frequency, failed submissions per transaction, support contacts, poll volume, and time from send acceptance to completed login. Provider latency, uptime, and savings require runtime evidence; none is implied here.

Choose the combined REST approach when integration effort dominates and application-owned abuse policy is acceptable. Choose Twilio Verify with Auth0 or Clerk when specialist verification and an established identity stack matter more than credential count. Choose the AWS pairing when IAM and regional operations are core competencies. For higher-assurance access, do not make SMS the only authenticator.

Further reading

Top comments (0)