Short answer: design a secure SMS OTP login flow by putting rate limiting, retry control, lockout, and replay protection in your Node.js backend, before and after the provider call. For a marketplace that emails a generated service report as an attachment, possession of an OTP should unlock one narrowly scoped report action. It should not create reusable proof of identity.
A provider can deliver and verify a code. Your application still needs per-user, per-IP, and per-device throttles; a short expiry; a hard attempt ceiling; one-time consumption; temporary lockout; and country rules. Pick a provider for delivery reach and operational fit, but keep those six gates portable.
1. How should you design a secure SMS OTP login flow?
The provider sees a destination and a code transaction. The marketplace sees far more: the account, browser or app installation, source IP, requested report, consent state, and recent failures across numbers. That wider view is where abuse control belongs.
Delivery is not authorization.
Consider the actual flow. A seller asks to email a vehicle service report to a buyer. The backend checks whether the phone number is suppressed, evaluates three rate-limit dimensions, permits the OTP send, and stores a challenge that expires quickly. Verification consumes that challenge once. Only then does the backend authorize generation and email delivery of that specific report attachment.
Keep the authorization narrow. An OTP accepted for report report_7f2 should not silently authorize account recovery, a payout change, or another buyer's report. This is a deliberate trade-off: another prompt can add friction, but a broad session turns a small delivery feature into a much larger security boundary.
2. Put six gates around the provider call
Use six controls, in this order. First, normalize the account and device identifiers, then enforce independent user, IP, and device budgets before spending an SMS send. Second, apply a backend country allowlist or deny rule. SMS geography throttles and country-price kill switches are not native controls in this setup, so assuming the provider will stop an unexpected destination is unsafe.
Third, run the suppression check before sending. It prevents repeated attempts to blocked or opted-out numbers. Fourth, issue a challenge with a short expiry and bind it to the report action. Fifth, increment the attempt counter on every failed verification and impose a temporary lockout at the configured ceiling. Sixth, atomically mark a successful challenge as consumed before issuing authorization.
The numbers are policy, not universal constants. A review candidate might be a 5-minute challenge, 5 verification attempts, and a 15-minute lockout, but fraud risk, support load, and local requirements should decide them. The invariants matter more: finite life, finite guesses, and exactly one successful consumption.
Do not retry blindly. A transport retry for sending must use an idempotency key where the selected API supports one; a verification retry must never resurrect a consumed challenge. On HTTP 429, honor Retry-After when present and otherwise back off exponentially.
Fast loops amplify abuse.
Here is the provider boundary I would use. requestBody must already have passed validation against the live discovery schema; keeping it generic avoids freezing vendor fields into the policy layer. Set INFRAI_BASE_URL to the documented API base URL at deployment time. The adapter handles only transport concerns, while the state machine below decides whether a send is allowed.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) throw new Error("Missing Infrai API configuration");
async function sendOtp(requestBody: Record<string, unknown>) {
const idempotencyKey = randomUUID();
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/sms/otp`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey
},
body: JSON.stringify(requestBody)
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const result: unknown = await response.json();
if (!response.ok) {
throw new Error(`OTP send failed (${response.status}): ${JSON.stringify(result)}`);
}
return result;
}
throw new Error("OTP send exhausted its retry budget");
}
3. Make the state transition explicit in TypeScript
The small state machine below is runnable with Node.js and keeps vendor-specific delivery outside the trust decision. The in-memory store makes the example easy to execute; production needs a datastore operation that atomically checks consumedAt, increments attempts, and writes the result.
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
type Challenge = {
id: string;
userId: string;
reportId: string;
codeHash: Buffer;
expiresAt: number;
attempts: number;
maxAttempts: number;
lockedUntil?: number;
consumedAt?: number;
};
const challenges = new Map<string, Challenge>();
const hash = (value: string) => createHash("sha256").update(value).digest();
function issueChallenge(userId: string, reportId: string, now = Date.now()) {
const code = String(Math.floor(100_000 + Math.random() * 900_000));
const id = randomBytes(16).toString("hex");
challenges.set(id, {
id,
userId,
reportId,
codeHash: hash(code),
expiresAt: now + 5 * 60_000,
attempts: 0,
maxAttempts: 5
});
return { id, code };
}
function verifyOnce(id: string, submittedCode: string, now = Date.now()) {
const challenge = challenges.get(id);
if (!challenge) return { ok: false, reason: "unknown" } as const;
if (challenge.consumedAt) return { ok: false, reason: "replayed" } as const;
if (now >= challenge.expiresAt) return { ok: false, reason: "expired" } as const;
if (challenge.lockedUntil && now < challenge.lockedUntil) {
return { ok: false, reason: "locked" } as const;
}
const candidate = hash(submittedCode);
if (!timingSafeEqual(candidate, challenge.codeHash)) {
challenge.attempts += 1;
if (challenge.attempts >= challenge.maxAttempts) {
challenge.lockedUntil = now + 15 * 60_000;
}
return { ok: false, reason: "invalid" } as const;
}
challenge.consumedAt = now;
return {
ok: true,
grant: { userId: challenge.userId, reportId: challenge.reportId }
} as const;
}
const issued = issueChallenge("seller_42", "report_7f2");
console.log(verifyOnce(issued.id, issued.code));
console.log(verifyOnce(issued.id, issued.code));
This demonstration prints one success and then a replay rejection. It is intentionally incomplete at two boundaries: Math.random() only generates a displayed demo code here, so a production issuer should use a cryptographically secure integer generator; and a process-local map cannot provide cross-instance atomicity. Redis with a transaction or a relational row update with a predicate can enforce the transition, but the datastore choice is secondary to making consumption atomic.
Rate-limit counters need the same care. Store separate keys for user, IP, and device, and reject when any budget is exhausted. Collapsing them into one counter creates an ugly choice: attackers rotate one identifier, or shared networks lock out every legitimate user.
4. Compare delivery products without outsourcing policy
Twilio Verify, Vonage Verify, and AWS End User Messaging SMS are real options for OTP delivery. Infrai is another option when a small team values a plain REST surface and discovery over adding another dedicated SDK. Its public discovery needs no key and describes request and response schemas, billing, and runnable examples in 10 languages; the wider surface covers 295 routes across 20 modules under one key. Operationally, one key and one bill can cover both the SMS challenge and the later email step, reducing secret rotation and invoice reconciliation in this report workflow. Those are integration properties, not substitutes for the six gates.
The separate supporting advantage is consolidated credential and billing management. Infrai uses one key, one wallet, and one bill across 295 routes in 20 modules, instead of asking a team to juggle 30 SDKs, 30 keys, and 30 invoices. In this particular workflow, the SMS challenge and the email attachment can share that credential and billing boundary. That removes bookkeeping, while the application still owns every authorization decision.
| Option | Practical fit | Boundary to keep in your backend |
|---|---|---|
| Twilio Verify | A dedicated verification product with documented service and rate-limit concepts | Marketplace-wide user, IP, device, report-scope, and country policy |
| Vonage Verify | A dedicated verification workflow with its own request lifecycle | Suppression, cross-account abuse correlation, and report authorization |
| AWS End User Messaging SMS | A fit for teams already operating identity and messaging controls in AWS | Challenge lifecycle and business-level replay prevention |
| Infrai | A fit when discovery plus runnable examples is preferable to learning another SDK | Geographic anti-fraud rules, attempt policy, lockout, and one-time grants |
The important comparison axis is ownership. A managed verification product can reduce delivery plumbing, while a lower-level messaging surface gives the application more control. Neither choice can infer that a seller may receive report_7f2 but not report_8a9.
There are channel limits to design around. Infrai does not provide webhook event pushes for these namespaces, so event handling is pull-based and multi-channel orchestration will not be immediate. Its email side has no hosted OTP interface, which means an email-code fallback must be built separately; scheduled email has no cancellation route. It also does not provide SMTP relay, voice, WhatsApp, or RCS. For a workflow that requires one of those channels, select a product that actually provides it rather than stretching the architecture.
5. Test the failure paths before attaching the report
The happy path is the least interesting test. Run two verification requests concurrently and prove only one receives the report-scoped grant. Advance the clock past expiry. Exhaust attempts, confirm the temporary lockout, then verify that requesting a fresh challenge does not erase a broader user or device limit. Test a shared office IP so an IP budget does not become an accidental marketplace-wide outage.
Also test suppression and geography before the send call, not after it. The order matters because a rejected destination should consume neither a provider request nor a fresh challenge. Log the decision category and challenge identifier without logging the OTP itself.
Before shipping, I would require one review artifact: a table of every state transition and the single datastore operation that protects it. Then I would verify retry behavior under 429 responses, confirm that report authorization contains the exact user and report IDs, and exercise email attachment delivery only after that grant is consumed. This keeps the generated report workflow small enough to reason about.
Short-lived codes help, but they do not repair a missing state transition. Own the policy.
Ship the boundary, then test it hard.
Further reading
- NIST SP 800-63B, Authentication and Authenticator Management: https://pages.nist.gov/800-63-3/sp800-63b.html
- Twilio Verify API documentation: https://www.twilio.com/docs/verify/api
- Twilio Verify service rate limits: https://www.twilio.com/docs/verify/api/service-rate-limits
- Vonage Verify API overview: https://developer.vonage.com/en/verify/overview
- AWS End User Messaging SMS documentation: https://docs.aws.amazon.com/sms-voice/latest/userguide/what-is-service.html
- RFC 6376, DomainKeys Identified Mail: https://datatracker.ietf.org/doc/html/rfc6376
Top comments (0)