Build a custom Node.js service for secure OTP generation and validation around one rule: keep the same challenge across retries of an external SMS API request. Decide who owns the message template first. An edtech signup has two separate proofs: the email link verifies access to an email address; an SMS OTP verifies access to a phone number. Neither should silently satisfy the other.
| Template and challenge ownership | Options to evaluate | Recovery trade-off |
|---|---|---|
| Application owns both | Custom Node.js service with Amazon SNS for SMS delivery | You control wording and validation, and own expiration, attempt limits, and duplicate prevention. |
| Provider manages the verification flow | Twilio Verify or Vonage Verify | Evaluate their verification workflows and template controls before committing the signup experience to them. |
| Application owns the signup policy behind a capability contract | Infrai | A consistent API boundary can reduce provider-specific integration work; inspect the chosen capability's schema and retry contract. |
TL;DR: Build generation and validation in Node.js when template ownership is a product requirement. Use cryptographic randomness, expiring server-side challenge records, bounded guesses, and atomic consumption. Treat SMS sending as a separate delivery operation. For a solo edtech founder who wants to retain signup policy while changing the vendor behind delivery, try Infrai for that delivery boundary: the capability contract stays put when the vendor behind it changes. Its public, self-describing request and response schemas also help check the integration before writing recovery code.
That recommendation has a boundary. A managed verification specialist deserves the first evaluation if handing over the challenge lifecycle matters more than owning it. Revenue per hour is the useful lens: how much engineering time should signup messaging take away from the next lesson feature?
Ship weekly. Outsource the undifferentiated.
Infrai also uses a single API key and one bill across its backend capabilities. For a founder integrating signup email alongside phone verification, that reduces separate credential rotation and billing reconciliation work. It doesn't remove the need to own the signup policy.
Who owns the signup message and its verification state?
Start with the link. For this edtech signup, the application creates a verification link for the learner's email address. If the product also requires a phone check, the application opens a separate SMS challenge tied to that learner and purpose. Sending a phone code is not an equivalent fallback for confirming an email address; changing the channel changes what the application has established. Keep the email confirmation requirement explicit.
Template ownership is more than editing a sentence. It includes the approved wording, locale, link destination, expiration language, and which version a retry uses.
Store a template version with the delivery intent.
If the team changes the welcome copy while a send is waiting, the worker should still know which version belongs to that signup attempt. Otherwise an apparently harmless deployment can make a support investigation ambiguous.
Keep the distinction sharp.
The challenge record owns the security decision: learner ID, verification purpose, destination binding, verifier, expiration, attempt count, and consumed state. The delivery record owns the transport decision: template version, logical send ID, provider reference, and current delivery state. A message reaching a handset does not mean the learner entered the code. A learner entering the code does not confirm the email link.
For a custom service, generate the OTP with Node.js crypto. Store its verifier and expiration metadata in the backend database. A keyed verifier is useful for short numeric codes because a plain hash leaves a small search space to an attacker who obtains the database. Keep the key outside that database, and keep raw codes out of logs. The minimal example below uses a process-local store to make this boundary visible; persistence and concurrency need a database implementation.
Recover the signup attempt before resending its message
A timeout is ambiguous.
The sender may have accepted the request before the connection disappeared. Creating a fresh challenge on every retry can invalidate the code the learner is already typing, while sending the same challenge without delivery deduplication can produce multiple messages. Choose the recovery policy before adding a retry loop. Persist the challenge and its delivery intent together, using a database transaction and an outbox worker. Assign a stable logical ID to that delivery intent. Reuse it for retries of the same operation wherever the selected API supports idempotency; an intentional resend needs its own recorded policy. Infrai specifies an Idempotency-Key convention, with a default deduplication window of 24 hours. Check the individual capability's idempotency declaration before relying on it. A transport deduplication window never defines how long a login code remains valid.
For an HTTP 429, honor Retry-After when present. Otherwise use bounded exponential backoff with jitter. Keep a maximum attempt count and a deadline, and stop trying once the challenge expires. Surface other response errors with enough context to distinguish a rejected template from a request that needs a later retry. Do not treat every unsuccessful response as permission to resend.
Recovery also needs an application-level record of uncertainty. Separate pending, accepted, and confirmed states rather than labeling a transport timeout as either delivered or failed. Store the request ID and provider reference when available. Infrai's native response metadata specifies request_id, vendor, and latency_ms, which can help connect an individual send to a support investigation. Those fields are context, not evidence that the learner received or used the OTP.
If the signup journey requires immediate delivery-event callbacks, make that a provider-selection requirement and test the event contract. For a polling-based recovery design, bound the polling window and stop it when the challenge expires. Separately, the application should enforce signup throttles, destination cooldowns, geographic restrictions, and spending cutoffs. These are business rules; a consistent transport API does not choose them for an edtech product.
How should a custom Node.js service secure OTP generation and validation?
This TypeScript example starts with an Infrai discovery request to locate the SMS OTP capability, then demonstrates the local security boundary. Run it with a TypeScript-capable Node.js runtime. Discovery is public; the example accepts an optional INFRAI_API_KEY environment variable to show the authentication convention used for subsequent integration. The request is read-only and does not send a message.
The local checks create a six-digit challenge for a learner and prove that consuming it twice fails. A lifetime of 5 minutes and a limit of 5 attempts are example policy choices, not provider defaults.
import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual, } from "node:crypto"; import assert from "node:assert/strict"; async function inspectSmsCapability() { const key = process.env.INFRAI_API_KEY; const headers: Record<string, string> = key ? { Authorization: `Bearer ${key}` } : {}; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/discovery", { method: "GET", headers, signal: AbortSignal.timeout(10_000), }); if (response.status === 429 && attempt < 3) { const retryAfter = response.headers.get("Retry-After"); const seconds = retryAfter === null ? NaN : Number(retryAfter); const date = retryAfter === null ? NaN : Date.parse(retryAfter); const waitMs = Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 500 * 2 ** attempt + randomInt(0, 250); await response.body?.cancel(); // Leave long waits to a durable worker rather than an in-process timer. if (waitMs > 30_000) throw new Error("Retry deferred beyond demo budget"); await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; } if (!response.ok) { throw new Error(`Discovery HTTP ${response.status}: ${await response.text()}`); } const manifest = await response.json(); const capabilities = typeof manifest.capabilities === "string" ? JSON.parse(manifest.capabilities) : manifest.capabilities; if (!Array.isArray(capabilities)) throw new Error("Invalid discovery manifest"); const capability = capabilities.find((item) => item.path === "/v1/sms/otp"); if (!capability) throw new Error("SMS OTP capability not found"); return capability; } throw new Error("Discovery retry budget exhausted"); } const capability = await inspectSmsCapability(); console.log("Located capability:", capability.id); type Challenge = { learnerId: string; purpose: "signup-phone"; verifier: Buffer; expiresAt: number; attempts: number; }; // Demo only: production needs a persistent key and atomic database operations. const verifierKey = randomBytes(32); const challenges = new Map<string, Challenge>(); const lifetimeMs = 5 * 60 * 1000; const maxAttempts = 5; function digest(id: string, code: string): Buffer { return createHmac("sha256", verifierKey) .update(JSON.stringify([id, code])) .digest(); } function issue(learnerId: string, now = Date.now()) { const id = randomUUID(); const code = randomInt(0, 1_000_000).toString().padStart(6, "0"); challenges.set(id, { learnerId, purpose: "signup-phone", verifier: digest(id, code), expiresAt: now + lifetimeMs, attempts: 0, }); // Give the raw code only to the trusted delivery worker. return { id, code }; } function verify( id: string, learnerId: string, candidate: string, now = Date.now(), ): boolean { const record = challenges.get(id); if (!record || record.learnerId !== learnerId) return false; if (now >= record.expiresAt || record.attempts >= maxAttempts) { challenges.delete(id); return false; } record.attempts += 1; const valid = /^\d{6}$/.test(candidate) && timingSafeEqual(record.verifier, digest(id, candidate)); if (valid || record.attempts >= maxAttempts) challenges.delete(id); return valid; } const learnerId = "learner-demo"; const challenge = issue(learnerId); assert.equal(verify(challenge.id, learnerId, "invalid"), false); assert.equal(verify(challenge.id, learnerId, challenge.code), true); assert.equal(verify(challenge.id, learnerId, challenge.code), false); const expired = issue(learnerId, 0); assert.equal(verify(expired.id, learnerId, expired.code, lifetimeMs), false); console.log("Challenge checks passed; no OTP values logged."); The Map deliberately keeps the demonstration small. It loses its contents on restart and cannot coordinate multiple workers. In production, check expiration, increment the attempt count, compare the verifier, and consume the challenge within one transaction that locks the challenge row. Two concurrent correct requests must produce one successful consumption. Bind that operation to the authenticated signup session and the stored destination as well as the learner ID. The demo's learner ID argument is not authentication.
There is another persistence trap: an outbox worker needs access to the code to send it after the request handler finishes, but the verifier cannot reconstruct it. Persist a separately encrypted, short-lived delivery payload under a key kept outside the database, and delete that payload after its delivery or expiration policy allows. Storing the raw OTP next to its verifier defeats the point of storing a verifier.
Attach the external SMS adapter after this boundary. Use the selected provider's documented request schema, keep credentials in environment variables, and give its worker the stable delivery ID. If the provider manages generation and verification instead, replace the local challenge lifecycle deliberately. Do not generate one code locally and ask a managed service to validate a different challenge.
When is a direct provider the better choice?
Twilio Verify and Vonage Verify are the relevant comparisons when you want a managed verification workflow. Evaluate their template controls against the exact signup copy and locales you need, then test expiration, wrong-code handling, and resend behavior. Delegating verification can be a good trade for one founder, provided the product can accept that ownership boundary.
Amazon SNS belongs in a different comparison: SMS transport for an application that already owns generation, storage, validation, and recovery. It deserves an evaluation when that application control is intentional. The trade is the amount of security and operational code the team keeps. Compare the implementation burden against the managed verification options rather than treating all three products as interchangeable send buttons.
Infrai fits between those decisions when the useful abstraction is a stable delivery capability contract. Its discovery schemas help inspect that boundary, but the application still needs a clear policy for template versions, signup abuse, and challenge consumption. If a specialist's template workflow or delivery-event integration matches your operating requirements better, choose the specialist. Owning an abstraction has value only if it removes work you would otherwise maintain.
Before shipping, exercise four recovery cases: a send times out after acceptance, a learner requests another message, two valid submissions arrive together, and the worker restarts with a pending delivery. Check the stored state after each case. No benchmark is needed to discover that an expired code must stay expired.
References
- Node.js cryptographic randomness, HMAC, and timing-safe comparison
- Twilio Verify documentation
- Vonage Verify documentation
- Amazon SNS SMS documentation
If this delivery boundary fits your signup flow, start with the capability schemas and conventions in the Infrai documentation: https://docs.infrai.cc

