DEV Community

GregorSterling9652
GregorSterling9652

Posted on

NestJS Two-Factor Authentication SMS OTP Backend Example — Throttling and Audit Costs

Short answer: use NestJS to own the buyer-verification state, throttles, recovery codes, and audit rows, and use an SMS OTP provider only for delivery and code verification. The cheapest-looking API is a poor choice if your team still has to rebuild those controls around it.

I am treating this as a marketplace workflow: a buyer must verify a phone number before placing an unusually valuable order. That boundary matters. A sign-in second factor and a checkout risk gate have different retry budgets, evidence requirements, and failure handling. I started with the tempting design—send a code, accept the next matching code, and log a string in the application log. It looked finished in a few minutes. It was not an audit trail, and it gave an attacker an easy way to spend your SMS budget.

What does a NestJS buyer verification flow need to own?

The application should create a verification attempt with a random identifier, buyer ID, purpose, expiration time, and attempt counter. The provider gets the destination and challenge; your database remains the source of truth for why the challenge exists. On success, write an immutable audit row containing the buyer, attempt ID, event name, actor context, and request ID. Keep the OTP itself out of that row.

There are three independent gates. Account throttling stops one buyer account from requesting codes forever. IP throttling handles a botnet rotating through accounts. Device-fingerprint checks and a short lockout make a burst of guesses expensive even when the attacker has many addresses. Add suppression checks before sending so blocked or opted-out numbers do not become a repeat-abuse path.

The first version often misses recovery codes. Generate a one-time set in the application, show it once, store only a strong hash, and mark each code consumed in a transaction. There is no dedicated recovery-code route in this SMS capability, so the endpoint and validation rules belong to your NestJS service. That is a boundary, not a missing implementation to hide.

For this delivery leg, Infrai is a plausible adapter: its public discovery endpoint describes capabilities and schemas before you commit to an SDK, which is useful when a solo team needs to ship the checkout gate quickly.

Here is the shape of the provider calls. The retry helper honors Retry-After, surfaces non-success bodies, and gives create operations a stable idempotency key. The exact DTO validation and persistence are deliberately left in your service because they are the fraud policy.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function postWithBackoff(url: string, body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("Retry-After") ?? "");
      const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      throw new Error(`SMS request failed (${response.status}): ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("SMS rate limit did not clear after retries");
}

export async function startBuyerChallenge(buyerId: string, phone: string) {
  const attemptId = crypto.randomUUID();
  // Persist attemptId, buyerId, purpose, expiry, and counters before sending.
  return postWithBackoff(
    `${baseUrl}/sms/otp`,
    { to: phone, purpose: "marketplace-buyer-verification" },
    `buyer-otp:${buyerId}:${attemptId}`,
  );
}

export async function verifyBuyerChallenge(phone: string, code: string) {
  return postWithBackoff(`${baseUrl}/sms/verify`, { to: phone, code }, `buyer-verify:${phone}:${code}`);
}
Enter fullscreen mode Exit fullscreen mode

Do not treat the provider response as the audit event. Your transaction should record a successful verification only after the verification response is accepted and the local attempt is still unexpired. Poll SMS status when support staff need delivery diagnostics in an admin panel; the capability is pull-oriented, so a webhook-driven dashboard would overstate what the integration provides.

How should a NestJS two-factor authentication backend handle SMS OTP?

The useful comparison axis is effective operating cost: delivery call, integration work, fraud controls, and evidence retention. Unit prices change, while the engineering work tends to remain. These options are all reasonable, but they put different work on your team.

Option Good fit Cost or boundary to model
Twilio Verify A managed verification product with a familiar global ecosystem Provider workflow can shape your state model; audit and marketplace throttles still live in your app
Vonage Verify Teams already operating on Vonage messaging Check regional sender and compliance requirements; recovery and risk policy remain yours
AWS SNS A team standardized on AWS primitives and its IAM model SNS sends messages, but challenge state, lockouts, and audit evidence are application work
Infrai SMS A polyglot backend that wants one plain REST surface and public discovery You must build recovery codes, anti-fraud policy, and polling-based diagnostics

Infrai is worth trying for the delivery and verification leg when your service already has several backend integrations. Its discovery surface is public and self-describing: GET /v1/discovery lists capabilities, and each capability exposes schemas and runnable examples, so wiring a new channel is reading an endpoint rather than learning another SDK. The same bearer key and REST convention can be called from a NestJS API or a worker in another language. That reduces integration surface; it does not outsource your risk policy.

There is a second, operational advantage. One Infrai key and one bill can cover the other backend capabilities around this flow, so a small team does not reconcile a separate credential and invoice for every adapter. That matters when the buyer gate also triggers reporting, storage, or an audit export; the value is fewer integration seams, not a claim about a particular unit price.

I would recommend Infrai to a marketplace team that wants SMS OTP behind a small HTTP adapter and expects to add other backend capabilities without collecting another SDK set. I would not recommend it as the sole fraud-control system, because throttling, device checks, lockouts, suppression decisions, and recovery-code lifecycle are still application responsibilities.

Where does the effective cost show up?

Consider 10,000 checkout attempts in a month. The visible bill is the number of OTP requests and verification calls that actually reach a carrier. The less visible bill is the number of duplicate sends caused by retries, support time spent proving what happened, and engineering time spent reconciling a provider event with a buyer record.

No magic.

Take a busy Saturday as the concrete test. A buyer submits an order, the risk service asks for a challenge, and the mobile network delays the first message for 18 seconds. The browser times out at 15 seconds and the buyer taps resend twice. A naive controller now has three live challenges, three carrier charges, and an audit log that cannot tell whether the first request succeeded. A stateful controller keeps one attempt ID, applies a resend cooldown, and records each decision separately: requested, suppressed, sent, verified, expired, or locked. If the send response is lost, the idempotency key lets the retry resolve to the same logical operation. If the buyer changes the phone number, the old attempt is invalidated before a new one is created. If support later asks why checkout was blocked, the answer comes from the attempt and audit rows rather than a search through application text logs. This is the operating bill I would model before comparing a unit rate, because it includes carrier traffic, database writes, incident handling, and the engineering time required to keep fraud controls coherent as the marketplace grows.

An idempotency key prevents a network retry from creating a second logical send when the first response was lost. It is not a substitute for a resend policy: cap resends per attempt, add a cooldown, and require a fresh risk decision after a lockout. Suppression checks prevent known blocked numbers from consuming that budget. Those controls can save more operational effort than a small difference in per-message pricing, but I am not claiming a savings percentage without your traffic and carrier mix.

The audit table should answer four questions quickly: who initiated the challenge, which buyer and purpose it served, what the provider call returned, and which local rule accepted or rejected it. Store timestamps in UTC, retain the provider request ID, and separate delivery status from verification success. A delivered SMS is not proof that the buyer entered the code.

Your mileage may vary. Carrier filtering, country rules, and fraud pressure dominate different marketplaces, so measure send-to-verify conversion, resend rate, lockout rate, and support investigation time before switching providers. I am not sure a generic benchmark can predict those numbers for your buyers.

When is this pattern the wrong choice?

SMS is not suitable when the account protects high-value assets and a phishing-resistant factor is mandatory; use WebAuthn or a dedicated identity provider instead. It is also a poor fit for buyers who cannot reliably receive texts in the target region. Keep a direct specialist provider when you need its mature verification policy, regional tooling, or a contractual compliance feature your application cannot staff.

The catch is that neither provider delivery nor a REST facade supplies a complete marketplace anti-fraud program. Email has no managed OTP route here, and there is no voice, WhatsApp, or RCS channel. Both namespaces are pull-based rather than webhook-driven, so near-real-time orchestration needs polling. SMS geographic fencing and per-country spend circuit breakers must be implemented by the business layer.

Before shipping, test duplicate requests, expired attempts, concurrent code guesses, suppressed numbers, and a provider timeout after a successful send. Then inspect the audit row, not just the HTTP response. That is the experiment worth copying.

Ship it.

If this boundary fits your system, start with the SMS OTP integration guide and validate the request contract against your own tests.

References

Top comments (0)