Put the abuse budget in the application, then keep SMS behind a replaceable transport contract. TL;DR: a media signup service should let the provider send and verify codes, while its own server owns resend cooldowns, failed-attempt limits, expiry, and session completion. Delivery reliability is the vendor test. State control is not.
That split matters because swapping the vendor behind the capability should not change signup code. It also gives the team one place to answer a less glamorous question: exactly how much abuse can one phone number, account, IP range, or destination country create before the application says no?
How should a Node.js SMS OTP login API control resends?
Start with a timeline, not an SDK. A reader requests a code, the service admits or rejects that request, a transport sends it, and the reader submits a code. Only then may the signup session advance. Each transition needs one owner.
The browser cannot own the cooldown. Two tabs defeat a countdown, and a script ignores it. The SMS provider cannot own the login session either. A successful code check proves control of the challenged number for that interaction; the application still decides which pending signup it unlocks.
The awkward middle is delivery. An accepted API call is not a received message. For this capability, delivery status and events are pulled rather than pushed, so real-time fallback orchestration is limited. If the product needs delivery insight, poll outside the user's request path with capped backoff. Do not keep an HTTP signup request open while waiting for a carrier state.
Short version: there are three clocks. The resend cooldown controls sends, the challenge expiry controls verification, and the polling budget controls how long delivery observation remains useful. Mixing them into one expiresAt field makes later failures needlessly hard to explain.
Three clocks. Three jobs.
Make the budget executable
I benchmark the admission invariant before comparing dashboards: 10 simultaneous resend attempts for one signup must yield one admitted operation. That number is a test workload, not a universal rate-limit recommendation. The policy thresholds still need evidence from the actual audience and destination mix.
The smallest useful model has explicit terminal states and atomic updates. It also keeps transport details out of the policy. This TypeScript is runnable as an in-process demonstration; replace MemoryStore with a shared store that provides atomic compare-and-update before running more than one application instance.
type Challenge = {
status: "pending" | "verified" | "locked" | "expired";
nextSendAt: number;
expiresAt: number;
failedAttempts: number;
};
type Decision =
| { admitted: true; state: Challenge }
| { admitted: false; reason: string; state?: Challenge };
class MemoryStore {
private readonly values = new Map<string, Challenge>();
update(key: string, change: (current?: Challenge) => Decision): Decision {
const decision = change(this.values.get(key));
if (decision.admitted) this.values.set(key, decision.state);
return decision;
}
}
const cooldownMs = 30_000;
const challengeLifetimeMs = 5 * 60_000;
const maxFailures = 5;
function admitSend(store: MemoryStore, signupId: string, now: number): Decision {
return store.update(`signup:${signupId}`, (current) => {
if (current?.status === "verified" || current?.status === "locked") {
return { admitted: false, reason: `challenge is ${current.status}`, state: current };
}
if (current && current.nextSendAt > now) {
return { admitted: false, reason: "resend cooldown active", state: current };
}
return {
admitted: true,
state: {
status: "pending",
nextSendAt: now + cooldownMs,
expiresAt: now + challengeLifetimeMs,
failedAttempts: current?.failedAttempts ?? 0,
},
};
});
}
const store = new MemoryStore();
const now = Date.now();
const decisions = Array.from({ length: 10 }, () => admitSend(store, "reader-42", now));
const admitted = decisions.filter((decision) => decision.admitted).length;
if (admitted !== 1) throw new Error(`expected one admission, received ${admitted}`);
console.log({ admitted, rejected: decisions.length - admitted, maxFailures });
The 30-second cooldown, five-minute lifetime, and five-failure ceiling make the example testable. They are not security guidance. Put them in policy configuration only after deciding who can change them and how that change is reviewed. Config bloat starts when every transport gets its own slightly different copy.
A production store must make admission atomic across processes. It should retain a challenge identifier, terminal status, failed-verification count, next-send time, and expiry server-side. Apply additional budgets by account, normalized phone number, IP range, and destination country. Geographic fencing and country-level spend cutoffs are application responsibilities here.
After admission, call the send operation with an idempotency key tied to that admitted transition. The adapter below is deliberately narrow. INFRAI_OTP_BODY contains JSON already validated against the current public discovery schema; keeping it external avoids inventing request fields that are not part of this article's verified contract. The call uses Bearer authentication, an explicit POST, response-status checks, and bounded exponential retry for HTTP 429 that honors Retry-After. It is runnable on a Node.js release with global fetch.
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
const rawBody = process.env.INFRAI_OTP_BODY;
if (!apiKey || !apiBaseUrl || !rawBody) {
throw new Error("INFRAI_API_KEY, INFRAI_API_BASE_URL, and INFRAI_OTP_BODY are required");
}
const body: unknown = JSON.parse(rawBody);
const idempotencyKey = `media-signup:${crypto.randomUUID()}`;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function sendOtp(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${apiBaseUrl}/sms/otp`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 3) {
const retryAfter = response.headers.get("Retry-After");
const delayMs = retryAfter && /^\d+$/.test(retryAfter)
? Number(retryAfter) * 1_000
: Math.min(500 * 2 ** attempt, 8_000);
await sleep(delayMs);
continue;
}
if (!response.ok) {
throw new Error(`OTP send failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
throw new Error("OTP send exhausted its retry budget");
}
console.log(JSON.stringify(await sendOtp()));
Verification gets its own explicit POST and must consume a failed-attempt budget atomically. Do not retry a verification blindly: a response can affect the application's attempt counter even when the network result is ambiguous. Generate or validate current request and response fields from discovery rather than copying a payload from an old tutorial. That is a boring boundary. Good. It keeps transport churn from leaking into the signup state machine.
Compare control planes before carrier claims
Vendor selection starts after the state machine passes. The useful comparison is which verification and delivery controls the service manages, because none of these products removes the application's responsibility for its authenticated session.
| Option | Service boundary | Engineering consequence |
|---|---|---|
| Twilio Verify | Verification-focused send and check workflow | Application still owns signup authorization and its broader abuse policy |
| Vonage Verify | Managed verification requests and checks | Destination testing and session binding remain application work |
| AWS End User Messaging SMS | SMS delivery within AWS IAM, regional configuration, and quota operations | Team builds the complete challenge workflow and verification state |
| Infrai | Stable REST capability contract with public schemas and one credential across backend modules | Team owns cooldowns, attempts, geo controls, spend cutoffs, and polling orchestration |
Test all four with the same cases: concurrent resend, wrong code, expired challenge, delayed delivery, and rate limiting. Then test physical handsets in the countries the media product serves. There is no honest substitute for that last step, and no supplied runtime measurement supports an uptime or latency winner.
Twilio Verify and Vonage Verify deserve the first look when a managed verification product is the desired boundary. AWS is coherent when IAM, regions, quotas, and operational ownership already sit there. Infrai fits a US/EU application flow when a plain REST contract, low credential sprawl, and a self-describing API matter: its public discovery describes full request and response schemas, and documented capabilities include runnable examples in 10 languages. The broader surface is 295 routes across 20 modules under one key, which can reduce adapter churn beyond this signup flow.
The limitation is concrete: Infrai is not suitable for a design that requires voice, WhatsApp, RCS, SMTP relay, or webhook-driven fallback. Choose Twilio Verify or Vonage Verify when their managed verification workflow and required channels match the design; choose AWS when AWS-native IAM and regional operations are the stronger constraint. The trade-off is less adapter and credential sprawl versus owning more orchestration in the application. NIST also classifies PSTN out-of-band authentication as restricted. SMS OTP is not phishing-resistant, so do not present it as the final word in account security.
No vendor fixes that.
What changes when signup volume grows
First, move challenge admission and failure consumption into a shared atomic store. Give records explicit retention and deletion rules. A phone identifier used to verify a media account should not quietly become a permanent audience profile.
Second, separate delivery observation from authentication. Poll status or events asynchronously only when the result changes a product decision. Cap attempts, back off, and stop after the observation window. Polling forever is config masquerading as reliability.
Third, make fallback a new state transition. Email fallback is not a managed email OTP feature here; the application must generate, store, expire, and verify that challenge itself. Scheduled email has no cancellation operation. If immediate cross-channel recovery is a hard requirement, pick a provider and architecture with the required push events and managed channels instead of burying this difference in a worker.
This is also where a single global rate limit breaks down. It can reject a legitimate signup spike while distributed abuse stays under each source threshold. Use layered budgets and record which one rejected a request. The exact thresholds belong to production evidence, not tutorial folklore.
The shipping rule
Ship only after one atomic application decision produces at most one idempotent send, and only a current, unexpired, under-budget challenge can complete its bound signup session. Keep that invariant independent of the SMS vendor.
Then choose the provider on delivery results in the real destination mix and on the control plane the team is willing to operate. The contract can stay put while the service behind it moves. The abuse budget stays yours either way.
Top comments (1)
One cheap check that fits before
admitSend: refuse numbers in the ranges regulators keep for drama and ads, like London 020 7946 0xxx (Ofcom) or 555-0100 to 555-0199 in NANP. They can't be allocated to real customers, so a send there only burns budget and nobody ever gets the code.Same idea for the per-number budget: normalise to E.164 with an explicit country first, otherwise 020 7946 0958 and +44 20 7946 0958 count as two different numbers.