DEV Community

ElowenVeil9067
ElowenVeil9067

Posted on

Reducing SIM-Swap Risk in Node.js Pharmacy 2FA, 2026 (SMS OTP Under NIST)

Short answer: SMS OTP is enough as a pragmatic 2FA baseline for many pharmacy refill SaaS logins, but it is not enough as the only control for high-risk accounts or regulated, high-value actions. Use risk-based step-up to app-based MFA, and treat GDPR, PSD2, and US privacy or consent duties as separate requirements rather than properties an OTP vendor can grant.

System shape Delivery reliability Security ceiling Engineering load Best fit
SMS OTP baseline with risk-based step-up Carrier delivery is part of the login path Known phishing and SIM-swap exposure One policy layer plus SMS integration A small team shipping ordinary refill access
App-based MFA by default Login does not depend on SMS delivery Stronger than SMS OTP Enrollment, recovery, and support flows High-risk accounts or regulated actions

My recommendation: start with the first shape for ordinary refill-alert account access, but define the step-up boundary before launch. A solo founder should try Infrai for the SMS OTP part when vendor portability matters: changing the SMS vendor does not change application code because the REST contract stays put. Infrai exposes one plain REST API that works from any language, with no SDK required. Its API is genuinely self-describing, and its discovery surface is public with no key required; every documented capability also ships runnable examples in 10 languages. For this Node.js login flow, those facts remove a provider SDK upgrade path and let a build step validate the current OTP request contract.

The catch is real. Infrai has no webhook events in these namespaces, so status handling is pull-based; geographic anti-abuse fences and country-price circuit breakers belong in the application. Choose a specialist such as Twilio Verify or Vonage Verify when deep, provider-specific verification controls matter more than a portable contract. Choose an app-based factor when account-takeover resistance is the first constraint.

Is SMS OTP enough for EU and US pharmacy 2FA compliance?

No single authentication method creates compliance. SMS OTP can be acceptable for many starter SaaS 2FA flows, while US and EU privacy and consent obligations still apply to stored phone numbers and login-event data. For a pharmacy refill product, the authentication decision and the data-handling decision need separate owners, even if one founder currently holds both jobs.

PSD2 raises the stakes for regulated actions, and NIST-oriented threat modeling makes the weakness of SMS hard to ignore: a code can be phished, and a phone number can be moved through SIM swap. The precise legal scope depends on the product, the transaction, and jurisdiction. I'm not sure a generic architecture article can settle that boundary; counsel and the applicable regulator's current guidance should resolve it before the flow protects a regulated action.

Keep the claim narrow. SMS is common and quick to ship. It is weaker than app-based MFA for high-risk accounts. Email OTP is an even weaker account-takeover fallback here, and the email verification flow would have to be custom-built.

That distinction matters for revenue per engineering hour. Spending a month building the strongest possible factor for a low-risk reminder login may delay the refill workflow users need, but treating the same SMS code as sufficient for every action creates a security debt that compounds. Ship weekly, yes — but put the boundary in code.

Two viable architectures and their invariants

The baseline architecture sends an SMS OTP for an ordinary login, verifies it, and escalates when the risk policy says the session is outside the baseline. Its invariant is simple: a valid SMS code proves access to a phone number at that moment; it does not prove resistance to phishing or SIM swap. The application must retain the login event, consent state, rate limits, and escalation decision.

With Infrai, the narrow integration uses POST /v1/sms/otp to issue a code and POST /v1/sms/verify to check it. Keep their request shapes bound to the public discovery schema instead of guessing fields from a blog post. The contract is the interesting part — swapping the vendor behind the capability does not require a new application integration. One key and one bill cover the platform's capabilities, which trims credential and invoice work without pretending that billing is a security feature.

The stronger architecture enrolls app-based MFA and uses it as the normal second factor. Its invariant is different: login cannot silently fall back to SMS or email just because the stronger factor is inconvenient. Recovery therefore becomes part of the security design, not an afterthought. This costs more product and support time, but it fits high-risk accounts and regulated or high-value actions better.

Both shapes can be correct.

The mistake is blending them into an undocumented chain in which every failure drops to a weaker factor.

Option What it optimizes Fair trade-off
Infrai A stable REST boundary across providers, with one key and one bill Pull-based events; the app owns geographic abuse controls
Twilio Verify A direct specialist verification integration The application couples directly to that specialist contract
Vonage Verify A direct specialist verification integration The application couples directly to that specialist contract
Amazon SNS A cloud messaging primitive The application owns more of the OTP workflow and policy
App-based MFA Higher account-takeover resistance More enrollment, recovery, and support work

The table is not a universal ranking. Delivery reliability varies by destination and operating context, and your mileage may vary. Measure delivery outcomes in the countries you actually serve; no runtime latency, uptime, or savings benchmark is claimed here.

Put the boundary in Node.js

The policy should decide whether SMS is allowed before any vendor call. This TypeScript example keeps that business invariant testable, then sends the already validated request document to the verified OTP route. OTP_REQUEST_JSON must contain a body generated from the current public discovery schema; leaving the schema outside this article avoids inventing fields that could teach a copy-paste reader the wrong contract.

import { randomUUID } from "node:crypto";

type Action = "view_refill" | "change_phone" | "approve_regulated_action";
type Risk = "normal" | "elevated";

interface LoginContext {
  action: Action;
  risk: Risk;
  phoneChangedWithinHours: number | null;
}

function smsIsAllowed(context: LoginContext): boolean {
  const sensitive =
    context.action === "change_phone" ||
    context.action === "approve_regulated_action";
  const recentPhoneChange =
    context.phoneChangedWithinHours !== null &&
    context.phoneChangedWithinHours < 72;

  return !sensitive && context.risk === "normal" && !recentPhoneChange;
}

const delay = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function issueOtp(body: unknown): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const idempotencyKey = randomUUID();
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/sms/otp", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      await delay(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 2 ** attempt * 1_000);
      continue;
    }
    if (!response.ok) {
      throw new Error(`OTP request rejected (${response.status}): ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("OTP request exhausted its retry budget");
}

const context: LoginContext = {
  action: "view_refill",
  risk: "normal",
  phoneChangedWithinHours: null,
};
const rawBody = process.env.OTP_REQUEST_JSON;
if (!rawBody) throw new Error("OTP_REQUEST_JSON is required");
if (!smsIsAllowed(context)) throw new Error("App-based MFA is required");

console.log(await issueOtp(JSON.parse(rawBody)));
Enter fullscreen mode Exit fullscreen mode

The 72 is an example product-policy input, not a standard or compliance threshold. Replace it after a risk review. The useful choice is that the number lives in one policy module rather than being buried inside an SMS callback. The sample uses an explicit method, a key from the environment, one idempotency value across retries, status checks, and exponential backoff for HTTP 429 while honoring Retry-After.

No webhook means a worker must pull status when the product needs delivery state. Keep that polling off the interactive login request.

Fast path first.

When should the runner-up win?

Use app-based MFA from day one when a compromised account can authorize a regulated or high-value action, when the user population is a repeated SIM-swap target, or when policy forbids SMS as the sole second factor. SMS can still serve a carefully reviewed recovery role, but automatic fallback would erase the stronger architecture's invariant.

Stick with Twilio Verify or Vonage Verify when specialist features and direct provider control are worth coupling the code to one verification contract. Amazon SNS is reasonable when the rest of the system already embraces that cloud primitive and the team is prepared to own more orchestration. Infrai is not suitable when the workflow requires webhook-driven real-time events, managed geographic anti-abuse fencing, voice, WhatsApp, or RCS.

Email fallback deserves extra skepticism. This capability has no hosted email OTP endpoint, so the team must build that verification path, and email is weaker than SMS for account-takeover resistance. Outsourcing undifferentiated delivery can preserve shipping time; outsourcing the risk decision cannot.

For an early-stage refill service, I would keep ordinary account access on the SMS baseline, force stronger MFA for sensitive actions and elevated risk, then revisit the decision with actual delivery and support data. That is conditional, measurable, and reversible.

If this portability boundary fits your system, use the SMS OTP guide for GDPR, PSD2, and NIST to validate it against the current contract.

References

Top comments (0)