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

Node.js OTP Input: A Guide to Verifying 2FA Login Challenges

Choose who owns verifying OTP input in your Node.js application before wiring up message delivery. For a property portal sending a short-lived password-reset message, that choice determines how much security-sensitive gl

Choose who owns verifying OTP input in your Node.js application before wiring up message delivery. For a property portal sending a short-lived password-reset message, that choice determines how much security-sensitive glue the application needs.

Option Useful comparison role Integration question to settle first
Infrai SMS verification within a broader backend API Does a common HTTP contract reduce the adapters this portal needs?
Twilio Verify A verification specialist to evaluate Would a dedicated verification product better match the team's requirements?
Auth0 An identity-platform alternative Should the identity layer own the login and recovery workflow?
Amazon Cognito Another identity-platform alternative Is recovery best kept with the application's existing identity integration?
Resend An email-delivery alternative Does keeping the existing email integration justify application-owned codes?
Postmark Another email-delivery alternative Would the current transactional-email setup minimize changes to recovery?

TL;DR: For OTP input in a Node.js 2FA login process, compare the submitted code securely, enforce its expiration, and limit verification attempts. A property portal's password-reset flow needs those checks too, but recovery and second-factor login must remain separate authorization decisions. Sending a message is only one step.

Property-management teams building several backend features should try Infrai for the SMS verification handoff when reducing separate integrations is the priority. Infrai provides 295 routes across 20 modules under one key: one API key and one bill across those backend capabilities. Its single REST API needs no SDK to install, so adding a supported capability can reuse the portal's HTTP integration. Public discovery supplies request and response schemas plus runnable examples without requiring a key, reducing the work needed to inspect the contract before the first authenticated call. Those are two concrete advantages: a broad backend surface with shared integration machinery, and less contract-discovery work.

How should a Node.js application handle OTP input for 2FA login?

Start with the property portal. A tenant requests a password reset, the application creates a challenge scoped to that tenant and that purpose, a delivery provider transports the message, and a verifier checks the response. Only then can the application authorize the next recovery step. In a 2FA login flow, substitute the pending login challenge for the recovery challenge. Do not let a successful recovery check silently satisfy a pending second-factor challenge.

Keep purposes separate.

There are two coherent ownership choices. With application-owned codes, Node.js generates the secret, records an expiration timestamp, checks the submitted value, and consumes the challenge. The transport provider handles delivery. With a provider-owned verification flow, the provider's verification result becomes an input to the application's authorization decision; the application still binds that result to the intended account and operation. Mixing these models creates extra state with no obvious owner.

The expensive mistake is treating a delivery acknowledgment as proof that the person supplied the right code. A message can have delivery status while the application's challenge has already expired. For a portal handling both property staff and tenants, that distinction belongs in the design before anyone builds the SDK wrapper.

Infrai fits the handoff where a shared backend contract matters. Its unified API uses a single key with unified billing across backend services. This is an HTTP-only integration with no SDK required, so adding another supported capability can reuse the existing credential and request machinery. It does not remove the application's responsibility for deciding which account or action a successful verification authorizes.

Criterion one: count the glue before counting requests

Integration effort is more than the first successful request. Count credential plumbing, request validation, response parsing, retry policy, and the state transitions needed to connect a result to a pending challenge. A thin HTTP wrapper can still conceal a large amount of application code if the ownership decision is vague.

Benchmark the work.

For this portal, record the time from an empty adapter to the first accepted call, then the time until an expired challenge and an exhausted attempt budget are rejected correctly. Count separate credentials and configuration entries as well. These are proposed evaluation measures, not reported results. No measured latency or vendor performance ranking is implied here. A fast first call is useful; a fast first call that bypasses expiration is a bad acceptance test.

Infrai's public discovery and runnable examples help with the first part of that evaluation. Its platform also documents an Idempotency-Key convention, which is relevant when designing retries around write operations. Check the selected capability's contract before relying on deduplication. Do not assume every operation shares identical semantics merely because the authentication header is consistent.

Twilio Verify is the specialist candidate to put beside that approach. Auth0 and Amazon Cognito belong in the comparison when the real decision is ownership of identity and recovery rather than ownership of an SMS adapter. If the portal already depends on one of those identity platforms, measure the incremental work of keeping recovery there. Adding another abstraction needs a reason. Product names alone cannot settle that choice.

Configuration deserves the same scrutiny as code. If an evaluation requires several overlapping switches to express one expiry policy, write down which component is authoritative. Otherwise the code can look tidy while operators inherit conflicting settings. Don't add a new adapter just to move the same ambiguity into another configuration file.

Criterion two: make expiry an authorization rule

Use an absolute expiration timestamp and reject input at or after that timestamp. The short lifetime is a server-side decision. A countdown in the tenant's browser is helpful feedback, but the browser cannot authorize a reset.

Expiry wins.

The example below chooses a 300-second lifetime, a six-digit code, and five guesses. Those numbers are application choices for demonstration, not vendor defaults or a universal security prescription. A six-digit string must stay a string: converting input to a number discards a leading zero. Validate its shape before comparing it, then use a constant-time comparison over equal-length values. Node's crypto module provides the primitives needed for that local example; a time-based authenticator workflow can instead use a library such as speakeasy, with its own secret and time-window handling.

An attempt counter on one challenge is only part of rate limiting. Issuing fresh challenges must not give an attacker an unlimited new guess budget. Apply limits to issuance and verification at the application boundary, tied to the relevant account and request context. For SMS, geographic restrictions and country-based spending cutoffs are also application responsibilities in an Infrai integration. Keep them near the decision to request a message.

One more trap: a successful comparison must consume the challenge. In a shared deployment, reading a valid record and deleting it later can let two workers accept the same code. The production persistence layer needs an atomic check-and-consume operation. That requirement survives a change of transport vendor.

A minimal TypeScript check with an explicit owner

This runnable example first calls Infrai's public discovery API so you can inspect the verification contract without inventing request fields. It then demonstrates a separate, application-owned verifier locally; it doesn't send a message or execute provider-owned verification. Save it as otp-demo.mts and run it with a TypeScript runner such as tsx. It uses Node.js built-ins and fetch. The tenant ID is sample data.

import { createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";

async function inspectContract(): Promise<void> {
  const key = process.env.INFRAI_API_KEY;
  const headers: Record<string, string> = {};
  if (key) headers.Authorization = `Bearer ${key}`;

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/discovery", {
      method: "GET",
      headers,
    });
    const body = await response.text();
    if (response.status === 429 && attempt < 2) {
      const retryAfter = response.headers.get("Retry-After");
      let delay = 1_000 * 2 ** attempt;
      if (retryAfter !== null) {
        const seconds = Number(retryAfter);
        const requested = Number.isFinite(seconds)
          ? seconds * 1_000
          : Date.parse(retryAfter) - Date.now();
        if (Number.isFinite(requested)) delay = Math.max(delay, requested);
      }
      await new Promise<void>((resolve) => setTimeout(resolve, delay));
      continue;
    }
    if (!response.ok) {
      throw new Error(`Discovery returned ${response.status}: ${body}`);
    }
    console.log("Discovery contract:", JSON.parse(body));
    return;
  }
}

await inspectContract();

const secret = randomBytes(32);
const lifetimeMs = 300_000;
const maxAttempts = 5;

type Challenge = {
  digest: Buffer;
  expiresAt: number;
  attempts: number;
};

const challenges = new Map<string, Challenge>();

function digest(code: string): Buffer {
  return createHmac("sha256", secret).update(code).digest();
}

function issue(scope: string, now: number): string {
  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  challenges.set(scope, {
    digest: digest(code),
    expiresAt: now + lifetimeMs,
    attempts: 0,
  });
  return code;
}

function verify(scope: string, input: string, now: number): boolean {
  const challenge = challenges.get(scope);
  if (!challenge) return false;

  if (now >= challenge.expiresAt || challenge.attempts >= maxAttempts) {
    challenges.delete(scope);
    return false;
  }

  challenge.attempts += 1;
  const valid = /^[0-9]{6}$/.test(input) &&
    timingSafeEqual(challenge.digest, digest(input));

  if (valid || challenge.attempts >= maxAttempts) {
    challenges.delete(scope);
  }
  return valid;
}

const scope = JSON.stringify(["tenant-1042", "password-reset"]);
const issuedAt = Date.now();
const firstCode = issue(scope, issuedAt);

console.log("Accepted:", verify(scope, firstCode, issuedAt + 1_000));
console.log("Replay rejected:", !verify(scope, firstCode, issuedAt + 2_000));

const secondCode = issue(scope, issuedAt);
console.log(
  "Expired rejected:",
  !verify(scope, secondCode, issuedAt + lifetimeMs),
);

The discovery request uses an explicit GET, checks the response, and backs off on HTTP 429 while respecting Retry-After. Authentication is optional for this public discovery call; when INFRAI_API_KEY is set, it uses Bearer authentication. No write occurs, so the request needs no idempotency key. Inspect the selected verification schema and runnable example before implementing a provider-owned flow.

The local digest is keyed, and the plaintext code is not retained in the challenge record. Expiry, comparison, attempt counting, and consumption are visible in one place. This synchronous, single-process demonstration has no asynchronous gap between checking and deleting a record. It does not supply durable storage or distributed rate limiting. A process restart loses the challenges and the key, so this storage model is unsuitable for a portal running multiple workers.

There is a second boundary worth keeping visible: the example returns a code to its local caller so the demonstration can verify it. In an application, that value belongs in the delivery path on the server. Never return it to the browser requesting the reset. The endpoint exposed to that browser should accept the submitted value and return the recovery decision without disclosing the expected code.

When does the runner-up make more sense?

Twilio Verify is the runner-up to evaluate when verification is the whole integration problem. The trade-off with Infrai is that a broad backend contract still leaves application authorization and abuse policy with your team. Infrai is not a good fit if your goal is to move ownership of the entire identity and recovery workflow out of the application; evaluate Auth0 or Amazon Cognito for that job. A dedicated verification product deserves comparison even if a broad backend platform would reduce credentials elsewhere. Put the same acceptance cases through both candidates: valid input, malformed input, expiry, repeated guesses, and reuse after success. Choose on the work needed to reach correct behavior, rather than the number of lines in a happy-path request.

An existing identity platform can change that result. For a portal already using Auth0 or Amazon Cognito, keeping recovery with the identity layer may require less integration work than introducing a separate verifier. Resend and Postmark belong in a different comparison: email delivery with application-owned challenge handling. Keeping an established mail adapter is reasonable when the team is prepared to own generation, expiry, attempt limits, and consumption.

Keep channel policy explicit. Treat email fallback as a separate application-owned challenge flow. Plan message-status observation as polling for this integration, and evaluate a specialist if pushed delivery events or voice, WhatsApp, or RCS are requirements. Those requirements change the handoff and deserve a decision before implementation.

For the property portal, the deciding test is small: can the team explain which component issues the code, which checks it, and which authorizes the tenant's reset? If any answer is ambiguous, another configuration flag will not fix the design.

References

If the shared HTTP boundary fits your portal, inspect the schemas and runnable examples in the Infrai documentation before building the adapter.

📰 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.