Node.js SMS Verification Code: Generating and Sending One-Time Passwords via API Integration
Short answer: use Node.js code to generate one-time passwords locally, send them via a generic SMS API integration, and persist an idempotent attempt record so delivery retries cannot create an ambiguous login.
If an SMS OTP protects a marketplace login, treat delivery as a state transition, not a single API call. Generate the code with a cryptographically secure source, store only a digest with an expiry and attempt counter, accept it exactly once, and make the send operation idempotent. The same discipline matters when the account later triggers a generated report: email delivery should be a separate job with its own status and retry policy. The integration boundary is part of the security design, not plumbing left for the last commit.
An OTP endpoint is small. Its failure surface is not.
How should Node.js code handle generating and sending one-time passwords via SMS?
A timeout does not tell the application whether the message was accepted. Retrying blindly can produce two valid-looking texts, confuse the user, and create an unnecessary delivery charge. Returning success before the provider has accepted the request creates the opposite problem: the login screen waits for a code that may never exist.
That is the boundary.
The useful boundary is an attempt record. It owns a random challenge, a normalized destination, an expiry, a delivery status, and a count of verification failures. A client-supplied request key lets the server replay the same result for a duplicate request. The provider adapter knows how to call an API; the account service decides whether another send is allowed.
For a marketplace, this boundary also protects a common workflow: a buyer signs in with an OTP, then requests a report of orders or seller activity. The report attachment should never be created as part of the OTP request. Authentication establishes a session; a queue later generates and emails the report. Mixing those paths makes a slow report look like an authentication failure.
A small TypeScript implementation with explicit state
The following example keeps the provider generic. SmsGateway can wrap an HTTP client for any service that accepts a destination and message. The example uses Node.js crypto.randomInt, whose range is exclusive at the upper bound, so the six-digit value is padded before it is sent.
import { createHash, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
const OTP_TTL_MS = 5 * 60 * 1000;
const MAX_VERIFY_ATTEMPTS = 5;
interface SmsGateway {
send(input: { to: string; body: string; requestId: string }): Promise<{
accepted: boolean;
providerMessageId?: string;
}>;
}
interface OtpAttempt {
id: string;
requestId: string;
destination: string;
digest: Buffer;
expiresAt: number;
verifyAttempts: number;
status: "pending" | "sent" | "verified" | "expired";
providerMessageId?: string;
}
const attempts = new Map<string, OtpAttempt>();
function digest(code: string): Buffer {
return createHash("sha256").update(code, "utf8").digest();
}
function sameDigest(left: Buffer, right: Buffer): boolean {
return left.length === right.length && timingSafeEqual(left, right);
}
export async function issueOtp(
destination: string,
requestId: string,
sms: SmsGateway,
): Promise<{ attemptId: string; status: OtpAttempt["status"] }> {
const existing = [...attempts.values()].find(
(attempt) => attempt.requestId === requestId,
);
if (existing) return { attemptId: existing.id, status: existing.status };
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
const attempt: OtpAttempt = {
id: randomUUID(),
requestId,
destination,
digest: digest(code),
expiresAt: Date.now() + OTP_TTL_MS,
verifyAttempts: 0,
status: "pending",
};
attempts.set(attempt.id, attempt);
const result = await sms.send({
to: destination,
body: `Your marketplace verification code is ${code}. It expires in 5 minutes.`,
requestId,
});
if (!result.accepted) {
attempts.delete(attempt.id);
throw new Error("SMS was not accepted");
}
attempt.status = "sent";
attempt.providerMessageId = result.providerMessageId;
return { attemptId: attempt.id, status: attempt.status };
}
export function verifyOtp(attemptId: string, candidate: string): boolean {
const attempt = attempts.get(attemptId);
if (!attempt || attempt.status !== "sent") return false;
if (Date.now() >= attempt.expiresAt) {
attempt.status = "expired";
return false;
}
if (attempt.verifyAttempts >= MAX_VERIFY_ATTEMPTS) return false;
attempt.verifyAttempts += 1;
if (!/^\d{6}$/.test(candidate) || !sameDigest(attempt.digest, digest(candidate))) {
return false;
}
attempt.status = "verified";
return true;
}
This is deliberately an in-memory sketch, not a production persistence layer. In a deployed service, attempts belongs in a database or a short-lived key-value store with a uniqueness constraint on requestId. The verification update must be atomic: two concurrent requests must not both observe sent and both mark the same attempt as verified. A transaction or compare-and-set operation provides that guarantee.
This example has a clear limitation: the in-memory map disappears on restart and cannot coordinate multiple Node.js processes. That is an intentional teaching boundary, not a storage recommendation. The trade-off is useful in a code review: the example stays readable, while production needs durable state and an atomic write.
Do not log the code, the full phone number, or the SMS body. Log the attempt ID, an appropriately masked destination, the request ID, elapsed time, final status, and a provider message ID when one exists. That gives an operator a trace without turning logs into a second credential store.
Where delivery reliability actually breaks
The code-generation step is rarely the hard part. The edges are.
First, normalize the destination before deduplication and sending. A phone number represented in two forms can bypass a rate limit or create two attempts. Use an established telephone-number parser and store the canonical representation; do not infer country codes from an arbitrary string.
Second, make limits explicit. A useful policy has separate limits for an account, a destination, an IP address, and a time window. A failed verification increments the attempt counter. A new issuance invalidates the previous active attempt for the same account unless the product deliberately supports parallel devices. The response should not reveal whether an account exists.
Third, define timeout semantics in the adapter. If the request fails before the gateway accepts it, a retry may be safe with the same requestId. If the client timed out after an acceptance was possible, the adapter must reconcile using the request key or provider status mechanism before creating another message. “Retry three times” is not a delivery design.
Fourth, test the clocks and races. Inject a clock into the service, test the exact expiry boundary, submit the same verification concurrently, and replay an issuance request. Add contract tests for accepted, rejected, timed-out, and rate-limited gateway responses. A staging number that always receives texts will not reveal duplicate sends or a broken idempotency key.
One small test is worth keeping visible: send the same request key twice while the first gateway call is unresolved, then resolve the gateway once. The second call must return the original attempt rather than send another message. That single race catches a surprisingly expensive class of integration mistakes.
How does OTP connect to the generated report email?
After verification, issue a session with the minimum scope needed for the marketplace action. When the user requests a generated report, persist a report job with an idempotency key, enqueue it, and return a job identifier. A worker can generate the attachment, validate its size according to product policy, and hand it to an email adapter. The email adapter should receive a stable message key so a worker retry does not create an uncontrolled set of duplicate emails.
Keep the two delivery paths observable separately. SMS metrics should include issuance acceptance rate, verification success rate, expiry rate, resend rate, and latency buckets. Report-email metrics should include job age, generation failures, attachment failures, and final delivery handoff. A shared request ID can connect the user action across services, but it should not be the OTP itself.
This separation makes the decision rule practical: if a user cannot authenticate, inspect the OTP attempt state and gateway handoff; if a verified user cannot receive a report, inspect the report job and email handoff. One status field cannot explain both.
An operational checklist that fits in the code review
Before shipping, confirm that the code is generated by a cryptographic random source, stored as a digest, short-lived, single-use, and capped by failed attempts. Confirm that the destination is canonicalized and that resend and verification endpoints are rate-limited without account enumeration. Confirm that duplicate requests reuse an idempotency key and that uncertain gateway outcomes have a reconciliation path.
Then exercise the failure cases: gateway rejection, network timeout after acceptance, database retry, worker restart, clock skew, two simultaneous verification requests, and a report email retry after attachment generation. Watch masked logs and traces while doing it. Reliability is the behavior users see after those cases, not the green response from the happy path.
References
- Node.js
crypto.randomInt: https://nodejs.org/api/crypto.html#cryptorandomintmin-max-callback - Node.js
crypto.timingSafeEqual: https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b - OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Multifactor Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
- RFC 7208, Sender Policy Framework: https://datatracker.ietf.org/doc/html/rfc7208
Top comments (0)