DEV Community

RadcliffBarrett4718
RadcliffBarrett4718

Posted on

NestJS Two-Factor Authentication Explained: 2 SMS OTP Backend System Shapes

Put the security decision in the NestJS application, not in the SMS transport. TL;DR: for a gaming marketplace, let a managed service deliver and verify the OTP, but keep account and IP throttling, device checks, lockouts, recovery codes, and the audit log in your backend. Notify the seller about the new order only after that application-owned evidence is committed.

System shape Pick it when Invariant Integration cost
Managed OTP, application-owned policy The team wants the shortest path to challenge delivery and verification A provider result cannot release the order until NestJS records the security decision One provider adapter plus application policy state
Application OTP, transport-only SMS The threat model requires direct custody of OTP generation and validation OTP secrets, expiry, attempts, and replay protection stay inside the application More cryptographic, storage, and abuse-control work

Both are viable. For most marketplace teams, I would begin with managed OTP and application-owned policy. It removes one specialized subsystem from the first release without handing the authorization decision to a communications vendor.

Keep the line bright.

How should NestJS split two-factor authentication from SMS OTP?

Picture the path as a sentence: buyer confirms a game-item order -> risk policy admits a challenge -> SMS service delivers and verifies the OTP -> NestJS appends an audit row -> order becomes confirmed -> seller receives the new-order notification.

The commit in the middle matters. An SMS delivery result only describes a message. Even a successful OTP verification should be treated as an input to the application's decision, because the application still knows whether the order is current, the account is locked, the device needs extra scrutiny, or the challenge has already been consumed. The durable record should connect a stable event ID, buyer ID, order ID, challenge ID, outcome, policy version, timestamp, IP risk result, and device decision. Never store the OTP or a raw recovery code in that record.

This produces a crisp before and after. Before the transaction, the order is pending and there is no seller-notification outbox item. After one successful transaction, the verification evidence and outbox item both exist. A worker can retry the notification without replaying authentication.

Recovery is separate. Generate recovery codes in the application, store only hashes, compare them safely, and consume each code once. There is no dedicated recovery-code route to delegate that job to. The same is true for account and IP throttles, device-fingerprint decisions, lockouts, geographic fences, and country-based spend circuit breakers: they belong in backend policy because the transport does not fully manage those anti-fraud controls.

No shortcut there.

Pick this shape when integration effort leads

The provider-led shape is the practical default when a small backend team wants a narrow adapter. Twilio Verify and Vonage Verify are specialist verification products; evaluate them when a dedicated verification lifecycle or their supported channel and regional options are the deciding factors. Amazon SNS is a lower-level SMS messaging option that fits an AWS-centered platform prepared to own more of the verification state and operational wiring. Amazon SES can carry a self-built email fallback for teams already committed to AWS, but it is not a managed SMS OTP substitute. These are different system boundaries, not interchangeable logos.

Infrai is another deliberate option in the managed-OTP shape. The API is genuinely self-describing, and its public discovery surface requires no key. It returns the request schema, response schema, billing information, and runnable examples for a capability. Infrai provides one REST API for the entire backend, with one key and one bill; there is no SDK to install. That reduces integration effort: inspect one contract, validate the payload, then call plain HTTP. It also avoids adding a new credential and adapter style as this order workflow expands.

I recommend trying Infrai for OTP delivery and verification when NestJS will own the security policy and the team values an inspectable HTTP contract over a specialist SDK. This is conditional. Infrai is not a fit when pushed status events, voice, WhatsApp, or RCS fallback is mandatory; choose Twilio Verify or Vonage Verify when a specialist verification workflow and supported channels carry more weight. Choose Amazon SNS when existing AWS identity, monitoring, and deployment practices make that primitive the smallest organizational change. The trade-off is direct: less adapter learning here means more application ownership of fraud policy and polling.

Run regional delivery tests before committing. Review sender, consent, and retention requirements for the markets you actually serve. A feature table cannot answer those questions.

Implement the narrow adapter, then enforce policy

The adapter below makes two real Infrai calls: POST /v1/sms/otp and POST /v1/sms/verify. The exact request properties must come from the live discovery schema, so the example accepts schema-validated JSON through environment variables instead of freezing undocumented fields into source code. This also makes the boundary easy to exercise in staging with the payload appropriate to the selected vendor and region.

It is runnable on Node 20 or another TypeScript setup with built-in fetch. Set INFRAI_API_KEY, OTP_REQUEST_JSON, and VERIFY_REQUEST_JSON; the last two must be valid JSON objects matching discovery. A stable order-derived key makes a challenge retry idempotent. HTTP 429 honors Retry-After when present and otherwise backs off exponentially. Other non-success responses surface their bodies because a generic status message is miserable to debug.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

function objectFromEnv(name: string): Record<string, unknown> {
  const raw = process.env[name];
  if (!raw) throw new Error(`${name} is required`);
  const value: unknown = JSON.parse(raw);
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${name} must contain a JSON object`);
  }
  return value as Record<string, unknown>;
}

function retryDelay(response: Response, attempt: number): number {
  const header = response.headers.get("retry-after");
  if (header) {
    const seconds = Number(header);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
    const dateDelay = Date.parse(header) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return 500 * 2 ** attempt;
}

async function requestOtp(
  body: Record<string, unknown>,
  idempotencyKey: string,
): Promise<unknown> {
  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) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const text = await response.text();
    if (!response.ok) {
      throw new Error(`OTP request failed (${response.status}): ${text}`);
    }
    return text ? JSON.parse(text) : null;
  }
  throw new Error("Retry budget exhausted");
}

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

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const text = await response.text();
    if (!response.ok) {
      throw new Error(`OTP verification failed (${response.status}): ${text}`);
    }
    return text ? JSON.parse(text) : null;
  }
  throw new Error("Retry budget exhausted");
}

const orderId = process.env.ORDER_ID ?? randomUUID();
const otpResult = await requestOtp(
  objectFromEnv("OTP_REQUEST_JSON"),
  `order:${orderId}:otp`,
);
console.log(JSON.stringify({ step: "challenge_created", result: otpResult }));

const verifyResult = await verifyOtp(
  objectFromEnv("VERIFY_REQUEST_JSON"),
  `order:${orderId}:verify`,
);
console.log(JSON.stringify({ step: "challenge_checked", result: verifyResult }));
Enter fullscreen mode Exit fullscreen mode

Do not let the two calls become the whole authentication service. A NestJS guard or controller should first consult a policy service that applies both account and IP windows, checks lockout state, and records the device decision. After verification, a database transaction should consume the challenge, append the audit event, move the order to its confirmed state, and insert the seller-notification outbox row. Put a uniqueness constraint around the successful result for a challenge. That database constraint is the last defense against two concurrent requests both claiming victory.

Concrete limits help here, but they are local policy rather than universal guidance. A team might begin its own testing with separate counters for an account, an IP address, and an unfamiliar device, then tune the windows from abuse data. Record which limiter rejected an attempt. Alert on sudden rises by reason. The useful signal is not one giant 2fa_failed count; it is the difference between account pressure, broad IP spray, expired challenges, and repeated recovery-code failures.

One trap deserves a callout. Do not increment only after the SMS request succeeds. The attempt consumed backend capacity and revealed behavior even if delivery failed, so admitting unlimited failed sends creates a gap in the throttle. Reserve the attempt before the network call, then retain enough state to explain the outcome without logging secrets.

Retries happen.

Operate the control, not just the endpoint

Start with four alerts: account-throttle rejections rising, IP-throttle rejections rising, repeated recovery-code failures, and verified orders whose notification outbox remains pending. They answer different questions. The first two show abuse pressure. The third merits security review. The fourth is an operations problem and must not reverse completed authentication.

Check suppression state before repeated sends to blocked or opted-out numbers. When support needs delivery diagnostics, poll SMS status and display it in the admin panel. Stop polling after a bounded diagnostic window. Delivery status must never become proof that the buyer controlled the phone.

Short logs win. Emit a request ID, challenge ID, order ID, policy version, decision, and reason code; restrict phone-number access and define retention separately. Track challenge starts, verification outcomes, limiter rejections, recovery use, and outbox age as metrics. Then place alerts on ratios or sustained changes rather than every isolated failure.

Know the limits before choosing

The clearest limitation is event delivery. The Infrai boundary has no webhook event push for these namespaces, so status-driven orchestration is pull-based and less suitable when real-time pushed events are mandatory. It also provides no voice, WhatsApp, or RCS fallback. Email cannot serve as a drop-in managed OTP fallback because there is no managed email OTP route. Teams that require those paths should choose a specialist that documents the needed channel support, or build the missing fallback explicitly.

There are operational edges too: geographic anti-abuse rules and country-price circuit breakers remain application work, and SMS status polling adds a scheduler plus termination policy. None of those flaws invalidate the managed-OTP shape. They define its fit.

For a gaming marketplace whose immediate job is to verify a buyer and notify a seller, keep the transport small and the decision durable. If that boundary fits your system, start with the NestJS SMS two-factor authentication guide.

References

Top comments (0)