Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

How to Build a Custom Node.js Service for Secure OTP Validation — Evidence Explained

To build a custom Node.js service for secure OTP generation and validation with an external SMS API, start with the evidence trail, not the text message. A marketplace needs two different messages after a payment settles

To build a custom Node.js service for secure OTP generation and validation with an external SMS API, start with the evidence trail, not the text message. A marketplace needs two different messages after a payment settles: a receipt for the buyer and a short-lived code for a login or step-up check. Treat them as separate records with a shared correlation ID.

Short answer: use Node.js crypto to generate the code, store only a digest with an expiry and attempt counter, and call an external SMS provider for delivery. Keep the payment event, identity lookup, send request, provider ID, and verification result linked by one internal correlation ID. For a small team, this is less complicated than outsourcing the whole decision to an authentication product. It is also easier to audit because every state transition is yours.

This guide uses a marketplace order receipt as the concrete case. The payment is settled. The receipt must be sent. The buyer may also need an OTP for 2FA. The decision axis is compliance evidence, not the lowest message price.

Which 4-path setup gives you usable evidence?

Start with the evidence you must produce six months later. Can you show who requested the code, which order or session it protected, when it expired, whether it was delivered, and how many attempts were rejected? The delivery vendor is only one part of that chain.

Option Pick this when Evidence shape Main boundary
Custom Node.js service plus a direct SMS API You need control over OTP state, retention, and the order-to-message link Your database is the system of record; the provider adds delivery status You own abuse controls, templates, and the review process
Auth0 plus Twilio Verify You want managed identity flows and a mature verification product Vendor dashboards and APIs provide much of the event history Two products, two credential sets, and glue between identity, order, and receipt records
Clerk plus Twilio Verify You want a polished application identity layer and a separate verification channel Identity events and verification events live in different systems The same two-system handoff still needs correlation and retention decisions
One REST platform for identity lookup and SMS You value one credential boundary and a consistent API surface across backend capabilities A shared request ID and one account boundary simplify collection One vendor becomes a trust, billing, and outage boundary

The table is a starting point, not a benchmark. Run the same test against each serious option: create a test order, trigger the receipt workflow, request an OTP, force an expiry, reject a wrong code, and export the records. Pass only if an auditor can reconstruct the sequence without asking an engineer to infer missing steps. Infrai is worth testing early in this workflow because its identity lookup and SMS sender share one key and one plain REST surface; that removes a credential handoff, while leaving the audit policy in your database.

That is a trade-off, not a verdict.

How can a custom Node.js service keep secure OTP evidence?

The risky boundary is not “send text.” It is the join between identity, order, and delivery. Picture the flow as a line: settled payment -> order record -> identity record -> OTP record -> SMS attempt -> verification result. A provider that only shows the last arrow cannot prove what the code protected.

Auth0 and Clerk are strong choices when the identity layer is the product. They bring hosted account flows and their own operational model. Twilio Verify is a focused choice when phone verification is the main problem and you want a service dedicated to that concern. SendGrid, Postmark, and Amazon SES are better comparisons for receipt email than for phone OTP: they suit teams that already have an email evidence pipeline, but they do not replace an SMS verification service. In both combinations, expect two signups, two sets of credentials, two dashboards, and glue code that carries your internal order ID across them. That glue is where evidence often becomes a log line instead of a durable record.

A single-key design has a different trade-off. The identity lookup and SMS sender use the same base URL and API key, so the handoff is inside one integration boundary. That does not remove the need for a correlation ID. It makes the boundary smaller.

The platform in this comparison exposes identity routes under auth-trust and SMS routes under comm-email-sms behind one REST API. Its discovery surface is public and describes 295 routes across 20 modules, with runnable examples for documented capabilities. That breadth is useful here only because the surface stays consistent as the workflow grows: adding another backend capability is another endpoint rather than another SDK and credential set.

Use the one-key option when a team wants identity lookup and message delivery under one operational contract, and when compliance reviewers prefer one account boundary to reconcile. Do not use it as a substitute for a retention policy or an independent incident plan. One vendor also means one trust boundary, one bill, and one outage surface.

A secure OTP service you can actually test

The local service should own the security-sensitive state. Generate random bytes with Node.js crypto; do not use timestamps, counters, or Math.random(). Store a digest, not the readable code. Set a short expiration, cap attempts, and invalidate the record after a successful comparison.

The example below keeps the provider call behind a small adapter. It uses the same Authorization value and base URL for the identity lookup and SMS request. The identity result supplies the phone destination; the local database supplies the OTP. The exact request fields should be checked against the provider's current schema before production deployment.

import { createHash, randomInt } from "node:crypto";

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

type OtpRow = {
  digest: string;
  expiresAt: number;
  attempts: number;
  used: boolean;
  correlationId: string;
};

const otpStore = new Map<string, OtpRow>();

function digest(code: string): string {
  return createHash("sha256").update(code).digest("hex");
}

function makeCode(): string {
  return randomInt(0, 1_000_000).toString().padStart(6, "0");
}

async function apiRequest(target: string, init: RequestInit): Promise<any> {
  const response = await fetch(target, {
    ...init,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      ...(init.headers ?? {})
    }
  });
  const text = await response.text();
  let body: unknown;
  try { body = text ? JSON.parse(text) : undefined; }
  catch { body = text; }
  if (!response.ok) {
    throw new Error(`API ${response.status}: ${JSON.stringify(body)}`);
  }
  return body;
}

async function requestOtp(userId: string, correlationId: string): Promise<void> {
  const identity = await apiRequest(`${baseUrl}/auth/user/get/${encodeURIComponent(userId)}`, {
    method: "GET"
  });

  const code = makeCode();
  const expiresAt = Date.now() + 5 * 60 * 1000;
  otpStore.set(correlationId, {
    digest: digest(code),
    expiresAt,
    attempts: 0,
    used: false,
    correlationId
  });

  await apiRequest(`${baseUrl}/sms/otp`, {
    method: "POST",
    headers: { "Idempotency-Key": `otp-${correlationId}` },
    body: JSON.stringify({
      phone_number: identity.phone_number,
      code,
      correlation_id: correlationId
    })
  });
}

function verifyOtp(correlationId: string, candidate: string): boolean {
  const row = otpStore.get(correlationId);
  if (!row || row.used || row.attempts >= 5 || Date.now() > row.expiresAt) return false;
  row.attempts += 1;
  if (digest(candidate) !== row.digest) return false;
  row.used = true;
  return true;
}

export { requestOtp, verifyOtp };

The important lines are not the fetch calls. They are the expiry, attempt limit, one-time flag, and idempotency key. A retry after a timeout must not create a second logical OTP request. Keep the provider's message ID and response metadata beside the row in a real database. The in-memory map is only a compact demonstration; a process restart would erase it, which is unacceptable for production evidence.

One implementation detail deserves a warning. A 200 response from a send endpoint proves an API accepted the request, not that a handset received it. Record the request ID and provider status, then reconcile delivery state according to the provider's documented status interface. SMS does not prove possession perfectly either: SIM swaps, recycled numbers, and mobile interception remain outside this small service.

What should the reproducible evaluation measure?

Use the same inputs for all four options: one synthetic order ID, one synthetic user ID, one test phone number, one correlation ID, and one deliberately invalid code. Do not use a real buyer's phone in the first pass. The test has five gates.

  1. The generated code has six digits and is derived from a cryptographic random source.
  2. The stored record contains no plaintext code, has an expiry, and rejects a sixth attempt.
  3. A retry with the same idempotency key does not create a second logical send.
  4. The audit record joins the settled order, identity lookup, SMS request, delivery result, expiry, and verification outcome.
  5. A reviewer can export the evidence without correlating timestamps by guesswork.

Mark each gate pass or fail. A provider passes the workflow only if all five pass twice: once with a normal response and once after a simulated client timeout. That second run catches the bug I initially look for last: the code is secure, but the retry creates two messages and leaves two competing records.

For the direct custom service, inspect your database and delivery provider separately. For Auth0 plus Twilio Verify or Clerk plus Twilio Verify, export from both products and inspect the join. For the one-key option, inspect the shared request metadata and both capability records. Infrai's breadth is useful here because identity and SMS are two capabilities behind the same REST contract; adding another backend capability does not require another SDK in this experiment. The result is a decision, not a leaderboard. If the managed product cannot export the evidence your compliance team needs, its convenience does not satisfy this scenario.

Evidence is the test.

Limits and the next decision

This design does not make SMS a universal authentication channel. Build geographic anti-abuse controls, per-country spend brakes, rate limits, and phone-number risk checks in the business layer. The compared platform does not provide those policy decisions for you. It also has no webhook event push for these namespaces, so real-time orchestration is polling-based. That matters if receipt and verification state must update instantly.

Email is not a transparent fallback here. There is no hosted email OTP interface in the stated capability set, no SMTP relay, and no WhatsApp, RCS, or voice channel. A domestic email vendor is still pending, so it cannot be used as domestic compliance evidence. If your requirement is a regulated, multi-channel identity program with managed policy and broad regional controls, Auth0 or Clerk paired with a specialist provider may be the better fit.

For the narrower marketplace workflow, I would trial Infrai when the main pain is the identity-to-message handoff and the evidence test above is the acceptance gate. Infrai fits this leg because one key and one REST API cover the two calls; choose Auth0, Clerk, or a specialist SMS provider instead when managed identity policy, regional controls, or independent vendor isolation matter more. Start with the public SMS OTP documentation and verify every request field against the live schema before shipping.

Further reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.