Node.js SMS Code via API (With Application-Owned Marketplace Templates)
Template owner What the application sends Main trade-off Marketplace application Destination and rendered SMS text Copy and code lifecycle need application releases and review Messaging service Destination, templat
| Template owner | What the application sends | Main trade-off |
|---|---|---|
| Marketplace application | Destination and rendered SMS text | Copy and code lifecycle need application releases and review |
| Messaging service | Destination, template identifier, and variables | Copy can change outside the application; template versions need coordination |
Short answer: For a marketplace contact form that needs phone verification before routing a request to support, start with application-owned SMS copy if developers already review those messages. Generate the OTP with Node.js cryptographic randomness, send it through a server-side API adapter, and consume it once during verification. Keep support queue selection on the server. Choose service-owned templates when the people maintaining copy need an independent publishing workflow.
Template ownership is the decision here. It doesn't determine who verifies the code: a service that renders SMS text doesn't automatically own the OTP lifecycle. The example below keeps generation and verification in the application, regardless of where the message is rendered.
Who should own the verification message?
Consider a buyer submitting a marketplace contact form about an order. The application needs to verify access to a phone number, then route the request to the appropriate support queue. SMS verification establishes access to that destination for this challenge. It doesn't establish ownership of an order or permission to read a seller's account. Those checks still belong to the authenticated application.
Separate those decisions.
For this design, the first criterion is who can approve a copy change. If a developer owns the contact flow and the SMS contains one sentence, a rendering function keeps the message beside its tests. A template registry, synchronization job, and environment-specific identifiers would add configuration without giving that developer a useful new capability. If a support operations team owns translations and copy approvals, keeping every edit behind an application deployment becomes the larger burden. That's a reason to move rendering, even while the application continues to issue and verify challenges.
The second criterion is whether the rendered message and the verifier agree about the challenge. An application-owned renderer can receive the same code that the verifier records. With service-owned templates, define the variable contract explicitly: which variable holds the code, what the message says about expiry, and which approved template revision gets deployed. A stale template that promises a longer validity period creates a support problem even when the verifier correctly rejects expired codes. For a concrete review exercise, take the example policy below: the application accepts a challenge for 300 seconds. Imagine a copy edit changes the message to promise ten minutes while the verifier still enforces five. The API request can succeed, the message can arrive, and the user can follow its instructions exactly, yet verification will fail during the extra interval. Testing only whether the template accepts a code variable misses that mismatch. Review the rendered sentence against the verifier's configured lifetime, and ship the copy and policy changes together when they depend on each other. This is a hypothetical failure to test, not an incident report.
Don't put queue identifiers in the verification message. Store the intended support action with the challenge on the server, and authorize the contact request after verification. A browser changing a hidden field from buyer support to seller support must not gain a different permission.
How does Node.js code send one-time passwords via an SMS API?
Here is a single-process TypeScript demonstration. The API integration uses an illustrative JSON contract: the messaging endpoint accepts to and text, and a successful HTTP response means the request was accepted. Replace that adapter with the documented contract of your actual endpoint. An endpoint under the reserved .invalid domain would be a placeholder, not a working SMS service.
The example chooses a 6-digit code, a lifetime of 300 seconds, a cooldown of 30 seconds between messages, and at most 5 verification attempts. Those are example policy values, not measured results or a claim that they suit every threat model.
Generating one-time passwords needs cryptographic randomness. Node.js documents that randomInt avoids modulo bias; Math.random() isn't an appropriate substitute for a security-sensitive code.
import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
type SupportScope = {
accountId: string;
contactRequestId: string;
phone: string;
};
type Challenge = {
digest: Buffer;
expiresAt: number;
retryAfter: number;
attempts: number;
};
// Demonstration only: process-local state is lost on restart.
const challenges = new Map<string, Challenge>();
const digestKey = randomBytes(32);
const lifetimeMs = 5 * 60 * 1000;
const cooldownMs = 30 * 1000;
function scopeKey(scope: SupportScope): string {
return JSON.stringify([
scope.accountId, scope.contactRequestId, scope.phone,
]);
}
function digest(key: string, code: string): Buffer {
return createHmac('sha256', digestKey)
.update(JSON.stringify([key, code]))
.digest();
}
async function sendSms(to: string, text: string): Promise<void> {
const endpoint = process.env.SMS_ENDPOINT;
const token = process.env.SMS_TOKEN;
if (!endpoint || !token) throw new Error('SMS configuration missing');
const url = new URL(endpoint);
if (url.protocol !== 'https:') throw new Error('HTTPS required');
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to, text }),
signal: AbortSignal.timeout(5000),
redirect: 'error',
});
if (!response.ok) throw new Error('SMS request rejected');
}
export async function issueCode(scope: SupportScope): Promise<void> {
// The server must derive and authorize this scope before calling.
const key = scopeKey(scope);
const now = Date.now();
const previous = challenges.get(key);
if (previous && now < previous.retryAfter) {
throw new Error('Wait before requesting another code');
}
const code = randomInt(0, 1_000_000).toString().padStart(6, '0');
const record: Challenge = {
digest: digest(key, code),
expiresAt: now + lifetimeMs,
retryAfter: now + cooldownMs,
attempts: 0,
};
challenges.set(key, record);
try {
await sendSms(scope.phone,
`Your marketplace support code is ${code}. Expires in 5 minutes.`);
} catch {
// Fail closed while preserving the cooldown.
if (challenges.get(key) === record) record.expiresAt = 0;
throw new Error('Verification message unavailable');
}
}
export function verifyCode(scope: SupportScope, input: string): boolean {
const key = scopeKey(scope);
const record = challenges.get(key);
if (!record || Date.now() >= record.expiresAt || record.attempts >= 5) {
return false;
}
record.attempts += 1;
if (!/^[0-9]{6}$/.test(input)) return false;
if (!timingSafeEqual(record.digest, digest(key, input))) return false;
// No await between lookup and consumption in this demonstration.
record.expiresAt = 0;
return true;
}
// Example server-side use after authentication and input validation:
// await issueCode({ accountId, contactRequestId, phone });
// If verifyCode(scope, submittedCode) succeeds, authorize and route
// that contact request using the server's stored support category.
The HMAC binds the code to an account, contact request, and destination. It also avoids storing the plaintext code in the challenge record. For this demonstration, the process generates its HMAC key at startup; restarting invalidates outstanding challenges. A deployed implementation needs deliberate key storage and rotation, plus shared challenge storage. Don't copy this map into a multi-worker deployment and expect workers to share state.
The integration has two configuration values. Keep it small. Neither belongs in browser code. Validate and normalize the phone number before calling this example, and derive the account and contact request from authenticated server state. A syntactically valid destination is still a destination someone can abuse.
There is a subtle boundary after verifyCode returns true. In the demonstration, consumption happens immediately in one process. In a deployed service, atomically consume the challenge and record authorization for that specific contact request, then let routing use the recorded authorization. Otherwise a process failure between consumption and routing can leave the user with an unusable code and no submitted request.
What should the benchmark actually measure?
A fast API response is a thin benchmark.
Measure time from form submission to messaging API acceptance separately from time to successful verification. The former measures your integration path; the latter includes delivery and the person's response. Neither number alone tells you whether users reached the right queue. Report a median and a tail percentile, such as p95, with sample counts and the test environment. Don't invent delivery latency from an HTTP response.
For the developer workflow, measure time to the first accepted test request and count the configuration values required to get there. Then change the SMS copy. Does that require editing one rendering function, registering a template, obtaining approval, or coordinating two deployments? That experiment exposes the ownership cost more clearly than an SDK feature checklist. No benchmark results are asserted here; these are measurements to collect in your own environment.
Test the boundaries.
Use a fake messaging adapter for repeatable tests. Check a code beginning with zero, an expired challenge, five failed attempts, a repeated successful code, and a request bound to another account or phone. Also simulate an accepted message whose HTTP response gets lost: the application can't infer non-delivery from a timeout. This example invalidates the challenge on send failure and retains the cooldown. That favors a clear verification boundary at the cost of potentially delivering an unusable code; document the retry experience in the form.
The demonstration's cooldown applies to one scope. It isn't a complete anti-abuse control: an attacker could create new contact requests or change destinations. Before exposing issuance publicly, enforce aggregate limits by account and destination, constrain request creation, and monitor abnormal sends. Verification attempts also need limits across replacement challenges. Otherwise requesting a new code keeps buying another set of guesses.
Shared storage must make attempt counting and consumption atomic across workers. Add expiry cleanup; the demonstration map retains records. Keep OTP values and SMS bodies out of logs, and use an opaque challenge identifier for correlation. Track accepted sends, verification failures, expiry, and completed routing without copying phone numbers into every event. Useful observability should explain where the contact flow stopped without turning logs into a second store of secrets.
SMS also has an authentication boundary. NIST treats authentication over the public switched telephone network as restricted and calls for consideration of risks such as number porting and SIM changes. Verifying a support destination doesn't turn SMS into phishing-resistant login or a sufficient authorization check for sensitive account changes. If this challenge is reused in an OTP/2FA login flow, review the authentication requirements separately.
When service-owned templates are the better fit
The runner-up is appropriate when copy ownership is outside the application team. For example, support operations may need to maintain multiple approved language variants while engineering keeps one issuance and verification contract. Move message rendering behind an adapter that accepts a template identifier and validated variables. Keep expiry, attempt limits, and action binding with whichever component actually owns verification.
Deployment then needs a template compatibility check: required variables exist, the approved revision is available in the target environment, and the rendered expiry matches the verifier. Record the template revision with send metadata. An identifier alone is a poor debugging aid if its content can change independently.
Application-owned copy makes developer review straightforward; service-owned copy makes an independent editorial workflow possible. Choose by the people accountable for the message and the release boundary they can maintain. For the marketplace contact form, the finish line is a verified, authorized request arriving in the correct support queue. Benchmark that path.
References
- Node.js crypto documentation: cryptographic integer generation, HMAC, and timing-safe buffer comparison. https://nodejs.org/api/crypto.html
- Node.js globals documentation:
fetchandAbortSignal.timeout. https://nodejs.org/api/globals.html - NIST Digital Identity Guidelines, Authentication and Authenticator Management: out-of-band authentication and restrictions on PSTN use. https://pages.nist.gov/800-63-4/sp800-63b.html
- RFC 7208 defines SPF for email sender authorization. It doesn't specify SMS OTP generation, verification, or delivery guarantees. https://datatracker.ietf.org/doc/html/rfc7208
Further reading
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.