DEV Community

UriahHawkins5489
UriahHawkins5489

Posted on

Cheap, Simple 2FA Login SMS API in Node.js — Direct Send vs OTP

Short answer: keep the password-reset or 2FA template in your application, send through a narrow SMS adapter, and verify the code on your server. A managed OTP endpoint is useful when you deliberately hand over template and verification ownership; it is the wrong default when the message wording, expiry policy, or audit trail is part of your product.

The data flow is short: a user asks for a reset, the application creates a random one-time value, a message adapter sends a rendered template, and a verifier consumes a hashed value once before its deadline. That separation lets a small Node.js service change SMS providers without rewriting login policy. It also gives you one place to rate-limit requests and redact phone numbers in logs.

A runnable Node.js shape

This example keeps the provider contract deliberately boring. SmsTransport can wrap an HTTP API, a queue, or a self-hosted gateway; the rest of the auth code does not need to know which.

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

type PendingCode = { digest: Buffer; expiresAt: number; used: boolean };

interface SmsTransport {
  send(input: { to: string; body: string }): Promise<{ messageId: string }>;
}

const pending = new Map<string, PendingCode>();

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

export async function issueResetCode(userId: string, phone: string, sms: SmsTransport) {
  const code = String(cryptoRandomInt(100000, 999999));
  pending.set(userId, { digest: digest(code), expiresAt: Date.now() + 10 * 60_000, used: false });
  await sms.send({ to: phone, body: `Your reset code is ${code}. It expires in 10 minutes.` });
}

export function verifyResetCode(userId: string, candidate: string): boolean {
  const record = pending.get(userId);
  if (!record || record.used || Date.now() >= record.expiresAt) return false;
  const candidateDigest = digest(candidate);
  if (candidateDigest.length !== record.digest.length || !timingSafeEqual(candidateDigest, record.digest)) return false;
  record.used = true;
  return true;
}

function cryptoRandomInt(min: number, max: number): number {
  const span = max - min;
  const value = randomBytes(4).readUInt32BE(0) / 0x1_0000_0000;
  return min + Math.floor(value * span);
}
Enter fullscreen mode Exit fullscreen mode

The in-memory map is only a runnable sketch. Production code needs a shared store with an atomic “consume if unexpired” operation, otherwise two application instances can accept the same code. Store a digest, never the code itself, and associate attempts with a reset transaction rather than only a phone number. A short expiry reduces replay risk; it does not stop a phone takeover, so offer a stronger recovery path for high-value accounts.

The useful mental model is a small state machine, not a message send. A request starts in issued, moves to dispatched after the transport accepts it, and can end as consumed, expired, or blocked. Persist the transition with an idempotency key derived from the reset transaction, so a client retry does not mint a second code. Keep the rendered template version beside the digest; support engineers can then answer “which words did this person receive?” without storing the secret. On a timeout, leave the state pending and retry the transport according to its contract. Do not silently create a replacement code, because a delayed first message may arrive after the replacement and confuse the user. A background job can remove expired rows, but verification must check the timestamp itself. This is more bookkeeping than a demo needs, yet it is the part that keeps a cheap, simple integration from becoming an account-recovery liability.

How should a simple 2FA login SMS API divide ownership?

Direct send means your service owns the template and asks an SMS API to deliver text. An OTP endpoint usually owns both code generation and verification, returning a handle that your service later checks. The names vary, but the ownership boundary is stable.

Concern Direct send OTP endpoint
Message wording Application controls it Service-defined or constrained template
Code state Your database and transaction Provider-managed state
Audit detail You can link event, user, and policy Often limited to provider event IDs
Migration Swap a transport adapter Re-map lifecycle and verification semantics
Best fit Product-specific reset policy Teams that want less auth plumbing

Neither choice makes SMS a proof of identity. Treat the channel as a possession signal, cap resend frequency, and require a separate session or recovery factor before changing an email address. For browsers that support it, the WebOTP API can help fill a received code, but the server remains the authority and must enforce expiry and single use.

Failure modes and fit after the demo

The first trap is a race: “check then mark used” is two operations. Use a conditional update or transaction so only one request wins. The second is template drift. A copy change in a dashboard can break parsing, localization, or a WebOTP origin-bound message, so version templates with the same care as code. It’s easy to miss this because the happy-path demo still works.

I once treated a ten-minute TTL as the whole policy and then noticed resend requests could extend that window forever. The fix was to keep the original deadline, add a per-user and per-IP budget, and emit an event for every issue, send, failure, and consume. A 429 response is useful feedback to the client; it should not reveal whether an account exists.

Keep phone numbers out of ordinary application logs. Record a stable user identifier, a provider message identifier, latency, and a coarse destination region. Delivery callbacks are evidence of handoff, not proof that a person saw the text. Your mileage may vary by carrier and country, so measure delivery and completion separately.

Small detail. It matters.

The catch is control. If compliance requires an exact disclosure, if support needs a complete reset timeline, or if the same code must unlock an email and SMS fallback under one transaction, a remote OTP state machine can become the awkward part of your system. Stick with direct send when those rules belong to your domain model.

Choose an OTP endpoint when the team cannot safely maintain code storage, replay protection, and abuse controls, and when its retention and regional-processing terms fit your requirements. Do not choose it because a per-message price looks small; the expensive failure is a lockout or an account takeover.

Before shipping, walk through the policy in prose: generate with a cryptographically secure source, persist a digest and immutable expiry, send a template version, throttle issue and verify attempts, consume atomically, and test delayed delivery. Then test provider timeouts and duplicate callbacks without issuing a second valid code. That checklist is the boundary between a simple SMS API integration and an authentication system you can explain during an incident.

Further reading

Top comments (0)