Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

Node.js Support-Queue OTP: Keeping SMS 2FA Templates Under Your Control

TL;DR: For a marketplace support console, keep OTP generation, expiry, attempt counting, and message templates in Node.js. Treat the SMS API as a delivery boundary. A six-digit code, a five-minute lifetime, a five-attemp

TL;DR: For a marketplace support console, keep OTP generation, expiry, attempt counting, and message templates in Node.js. Treat the SMS API as a delivery boundary. A six-digit code, a five-minute lifetime, a five-attempt ceiling, and a generic error are application policy, not provider policy.

The decision rule is simple: own identity and user-facing meaning; delegate transport. That keeps a contact form attached to the right support queue even when delivery is late, duplicated, or rejected. It also gives a solo team one place to review template changes.

How can I implement SMS OTP-based 2FA in a Node.js API?

The application decides who may enter a queue. A delivery adapter accepts a rendered message and destination, then reports an outcome. It never decides whether a code is valid.

This small TypeScript contract works over an HTTP gateway, a self-hosted relay, or a fake in tests. The storage methods need atomic behavior in production.

const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
const expiresAt = Date.now() + 5 * 60_000;
await sms.send({ to: phone, body: "Your support-console code is " + code, idempotencyKey: "support-login:" + challengeId });

\`ts
type Challenge = {
accountId: string;
queueId: string;
digest: string;
expiresAt: number;
attempts: number;
consumed: boolean;
};

interface ChallengeStore {
put(id: string, value: Challenge, ttlSeconds: number): Promise;
get(id: string): Promise;
replace(id: string, value: Challenge): Promise;
}

interface SmsGateway {
send(input: {
to: string;
body: string;
idempotencyKey: string;
}): Promise<{ accepted: boolean; providerMessageId?: string }>;
}

async function startLogin(
accountId: string,
queueId: string,
phone: string,
store: ChallengeStore,
sms: SmsGateway,
secret: string,
): Promise {
const id = crypto.randomUUID();
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
const expiresAt = Date.now() + 5 * 60_000;
const digest = await sha256(secret + ":" + id + ":" + code);

await store.put(id, {
accountId,
queueId,
digest,
expiresAt,
attempts: 0,
consumed: false,
}, 300);

const result = await sms.send({
to: phone,
body: "Your support-console code is " + code + ". It expires in 5 minutes.",
idempotencyKey: "support-login:" + id,
});

if (!result.accepted) {
throw new Error("Unable to send verification code");
}
}
`\

The queue identifier belongs in the challenge. A successful code must not be replayable to enter a different queue. The idempotency key makes a retry of the same application request distinguishable from a new OTP. Store only a digest, never the code itself.

Small detail, big consequence.

Verification should read, increment, compare, and consume through one atomic operation. Two concurrent requests must not both create sessions. A transaction or compare-and-set primitive handles that boundary; a sequence of independent reads and writes does not.

The numbers above are policy choices. Five minutes may be too short for a delayed carrier in one market and too long for a sensitive console in another. Measure expiry and resend behavior before changing them. Never log the token, full phone number, or rendered message.

What does the SMS API own, and what does it not own?

Template ownership is the useful boundary. The application owns wording, localization, code lifetime, the event that caused the message, and the support queue. The gateway owns carrier submission and delivery receipts. If a dashboard edit can silently change security wording, that is an approval problem, not an SMS feature.

Version the template with the challenge. A resend creates a new challenge and invalidates the old one. Do not put customer contact-form details into the message; a support agent may handle disputes, seller verification, and delivery complaints in the same console.

There are three reasonable integration shapes. A direct HTTP adapter gives control over request timing and composition. A managed verification flow takes on more lifecycle work, but its expiry and retry rules can become an external source of truth. A general messaging API leaves more state-machine code in the application. The right choice depends on how much policy the team wants to own.

Three products show the difference without establishing a ranking. Twilio Verify exposes a managed verification-resource lifecycle. Vonage Verify also provides a managed verification flow rather than a bare send primitive. Amazon SNS is a general publish-and-delivery service, so the application implements more OTP state management. Those are control-surface differences. Confirm current behavior and regional availability before selecting an API.

Approach Interface Useful when Main limitation
Managed verification SDK or REST The team wants delivery and challenge lifecycle in one service Template and retry policy may live outside the application
General messaging API REST or SDK The application needs broad message control The application owns more state, abuse controls, and testing
Direct gateway adapter REST A replaceable, narrow transport boundary matters Carrier behavior and delivery operations remain the team's job

The trade-off is real: managed verification reduces code, while an application-owned state machine preserves policy control. Neither is a universal fit.

Which failures should the login flow expose?

Return one broad response for an unknown destination, a throttled request, and a delivery failure. The client should not learn whether an account, phone number, or queue exists. Internally, retain a reason code and correlation ID so an operator can separate carrier rejection from an application timeout.

Rate limits need several keys: account or phone, source IP, and destination risk. A single global limit hurts a busy marketplace; only limiting the phone number lets an attacker rotate accounts. Add a send cooldown, and count a wrong verification attempt even when the submitted code is malformed.

SMS is a possession signal with weaknesses such as reassignment, interception, and social engineering. It can suit a low-friction support workflow. It should not be the only factor for changing payout details, exporting sensitive data, or changing another operator's access.

SMS also has a hard boundary: it cannot prove that the person holding a number is the right employee. Use a stronger factor or independent approval for the most consequential actions.

Do not put an OTP in a URL, analytics event, exception string, or browser persistence. Keep the response for an invalid challenge boring: a 401 is enough. The security work belongs in the state transitions, not in a verbose error body.

How do you test the integration without testing a dashboard?

Test the adapter as a contract. A fake gateway should record destination, body, idempotency key, and timeout behavior, then return accepted, rejected, delayed, and duplicate outcomes. The application tests should prove that a rejected send cannot verify, a resend invalidates the first code, and six wrong submissions cannot create a session.

Run tests just before expiry, at expiry, after expiry, with two verification requests at once, and after a network timeout followed by a retry. Add a log-redaction assertion; it is cheap to fail when a phone number or six-digit token enters structured output.

In production, measure request latency, acceptance rate, delivery latency when receipts exist, verification success, resend frequency, and expired-versus-consumed challenges. Retain provider message IDs only as operational references. Aggregate metrics are enough; support tooling needs a correlation ID, not message content.

The release checklist fits beside a deploy script: version the template, keep secrets out of source control, make challenge mutations atomic, bound provider retries, return generic client errors, and give delivery failures an escalation path. For this marketplace workflow, keeping identity policy and template ownership in Node.js makes the SMS integration replaceable without letting a carrier or dashboard edit redefine access to a support queue.

References

πŸ“° 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.