SMS OTP gets an airline disruption-alert login shipped quickly, but the hosted verify call is only half the system. Pick a managed OTP product, then own cooldowns, IP and device limits, country policy, retries, and delivery evidence in the application. Otherwise a delayed message looks like a bad code, a retry can become an SMS burst, and an attacker can turn the login form into a sending tool.
Short answer: use the provider's send and verify operations for token lifecycle, keep abuse decisions before the send, retry only transient failures, and poll delivery state when the provider has no webhook. This is the practical beginner path. It also leaves an audit trail that can answer who requested a code, which policy allowed it, what the provider returned, and whether delivery progressed.
How should a Node.js backend send and verify SMS OTP login codes?
Start with the operating model, not a feature count.
| Option | Pick this when | Main trade-off to test |
|---|---|---|
| Twilio Verify | You want a dedicated verification service and expect to use supported channels beyond SMS | Confirm channel, regional, and event behavior against your airline's compliance requirements |
| Vonage Verify | You want a managed verification workflow with its own request/check model | Map its workflow identifiers and status model into your evidence schema |
| Amazon Cognito | OTP belongs inside a broader managed user-directory and authentication flow | It is a larger identity-system choice, not merely an SMS adapter |
| Infrai | You want one API key and one bill across many backend capabilities, and can operate polling | SMS and email expose no webhook pushes; app-side orchestration carries more responsibility |
These aren't interchangeable wrappers. Twilio Verify and Vonage Verify center the verification workflow. Cognito owns more of identity. The fourth option is the breadth choice: 295 routes across 20 modules sit behind one key and a consistent contract, so a later backend capability does not require another SDK integration. Its public discovery surface also reports request and response schemas, billing metadata, and runnable examples. One credential and one bill can reduce access reviews and reconciliation work when the same alerting backend later adds storage or scheduling. For this login, however, breadth does not remove the need for local abuse policy or delivery polling.
The contract stays small.
Pick Twilio Verify when a specialized verification product and its documented channel model match the deployment. Pick Vonage Verify when its request/check lifecycle maps cleanly onto your existing authentication boundary. Pick Cognito when the team is deliberately adopting a user pool, tokens, and managed authentication as a unit.
Pick that consolidated platform when a plain REST surface reduces integration overhead across the wider alerting system. Do not pick it expecting built-in geographic fencing, per-country cost circuit breakers, managed email OTP fallback, SMTP relay, or voice, WhatsApp, and RCS channels. Those boundaries are architectural inputs.
Build the policy boundary before the provider call
Draw the flow in words: browser to login API; login API to policy store; policy store says allow or deny; only then does the API call the OTP provider. The verify path goes from browser to login API to provider, then to a short-lived authenticated session. Delivery polling runs off to the side and updates evidence. It never decides whether a submitted code is valid.
Use at least four keys for send controls: normalized account or phone reference, IP, device identifier, and destination country. A cooldown handles impatient repeat clicks. Rolling windows handle sustained abuse. A country allowlist prevents traffic outside the airline's service footprint, while a separate per-country circuit breaker can stop spend after an operational threshold is reached. The provider cannot infer those business rules.
For compliance evidence, record decisions rather than secrets. Useful fields are a server-generated attempt ID, event time, policy outcome, rule identifier, provider request ID when returned, HTTP outcome class, and a hash or internal reference for the subject. Never log the OTP. Avoid placing a full phone number in an unrestricted log stream.
The following runnable TypeScript module is intentionally provider-payload agnostic. That matters: the verified route and authentication contract are known, but copying guessed JSON field names would produce a dangerous example. Validate sendPayload and verifyPayload against the provider's current discovery schema at your adapter boundary.
const MAX_ATTEMPTS = 4;
type JsonObject = Record<string, unknown>;
export type OtpAdapter = {
send(sendPayload: JsonObject, idempotencyKey: string): Promise<JsonObject>;
verify(verifyPayload: JsonObject): Promise<JsonObject>;
};
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1_000;
}
return 250 * 2 ** attempt;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function readResponse(response: Response): Promise<JsonObject> {
if (response.ok) return (await response.json()) as JsonObject;
const errorBody = await response.text();
throw new Error(`OTP request failed (${response.status}): ${errorBody}`);
}
async function withRateLimitRetry(
makeRequest: () => Promise<Response>,
): Promise<JsonObject> {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
const response = await makeRequest();
const retryable = response.status === 429 || response.status >= 500;
if (!retryable) return readResponse(response);
if (attempt === MAX_ATTEMPTS - 1) return readResponse(response);
await sleep(retryDelayMs(response, attempt));
}
throw new Error("unreachable");
}
function configuration(): { apiKey: string; apiOrigin: string } {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiOrigin = process.env.INFRAI_API_ORIGIN;
if (!apiOrigin) throw new Error("INFRAI_API_ORIGIN is required");
return { apiKey, apiOrigin };
}
export const otpAdapter: OtpAdapter = {
async send(sendPayload, idempotencyKey) {
const { apiKey, apiOrigin } = configuration();
return withRateLimitRetry(() =>
fetch(new URL("/v1/sms/otp", apiOrigin), {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"idempotency-key": idempotencyKey,
},
body: JSON.stringify(sendPayload),
}),
);
},
async verify(verifyPayload) {
const { apiKey, apiOrigin } = configuration();
return withRateLimitRetry(() =>
fetch(new URL("/v1/sms/verify", apiOrigin), {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(verifyPayload),
}),
);
},
};
The send accepts an idempotency key because a timeout leaves the caller uncertain about whether the write happened. Reusing the same key makes the retry safe under the platform's idempotency convention, whose default deduplication window is 24 hours. Verification does not blindly retry every failure: a rejected code is a real result, not a transport accident. Keep that distinction sharp.
Add cooldowns without creating a race
An in-memory counter is a demo, not a multi-instance control. Put the decision and increment in one atomic operation in the shared policy store. A sensible state machine has three outcomes: allowed, cooldown, and blocked. The response to the browser should remain deliberately plain so it does not reveal whether an account exists.
Here is the application boundary. It is small on purpose, yet it captures the order that matters.
type SendDecision =
| { kind: "allowed"; attemptId: string }
| { kind: "cooldown"; retryAfterSeconds: number }
| { kind: "blocked"; ruleId: string };
type PolicyStore = {
decideAndRecord(input: {
subjectRef: string;
ip: string;
deviceId: string;
country: string;
}): Promise<SendDecision>;
};
export async function requestLoginCode(
policy: PolicyStore,
input: {
subjectRef: string;
ip: string;
deviceId: string;
country: string;
sendPayload: Record<string, unknown>;
},
): Promise<{ accepted: boolean; retryAfterSeconds?: number }> {
const decision = await policy.decideAndRecord(input);
if (decision.kind === "cooldown") {
return { accepted: false, retryAfterSeconds: decision.retryAfterSeconds };
}
if (decision.kind === "blocked") return { accepted: false };
await otpAdapter.send(input.sendPayload, decision.attemptId);
return { accepted: true };
}
Do not copy arbitrary numeric limits from another product. Choose them from threat modeling, expected passenger behavior, destination mix, and support capacity; then version the policy. The concrete number that matters in an audit is the limit active for that attempt, not a universal recommendation invented for a tutorial.
One easy mistake is checking a cooldown and incrementing it in separate calls. Two concurrent requests can both observe an open window and both send. The fix belongs in the storage operation, not in another if statement.
One transaction. One verdict.
How do delivery evidence and fallback work?
The message can be accepted by an API and still be delayed or fail downstream. Poll the SMS status or events resource by provider request ID, with a bounded schedule and jitter. Persist transitions, stop on a terminal state, and expire the polling job. Since this surface has no webhook event push, the evidence will be near-real-time rather than instantaneous. Be explicit about that latency in alerting objectives. Consider a concrete failure sequence: the provider accepts a send, the client times out, and the passenger taps again. The original idempotency key prevents the second send, while the polling worker continues tracking the first request. Without both controls, the UI cooldown can look correct while two messages still leave the provider.
Polling is part of the product here.
Watch four signals: send decisions by outcome and rule, provider responses by status class, time from acceptance to terminal delivery state, and verification outcomes. Alert on ratios and sustained changes, not individual passenger mistakes. A spike in policy denials may mean attack traffic; a growing delivery tail may mean carrier trouble. They demand different responders.
Email fallback is a separate authentication flow. Infrai has email sending but no managed email OTP endpoint, so the application must generate, store, expire, and verify email codes itself, or choose a managed identity product that owns that fallback. Email scheduling also has no cancellation operation. Do not label a generic email send as equivalent to hosted OTP verification.
For high-risk accounts, SMS should not be presented as phishing-resistant authentication. NIST's authenticator guidance treats PSTN out-of-band authentication as restricted and calls for considering risks such as SIM changes and number porting. The honest design is risk-tiered: SMS may fit a quick passenger login, while privileged airline operations deserve a stronger authenticator.
Limits to settle before launch
The launch review should resolve country allowlists, cost circuit-breaker ownership, polling cadence, evidence retention, log access, and the stronger-auth path. Also verify regional and vendor readiness for the destinations you serve. A pending domestic email vendor is not evidence of domestic compliance.
Keep the boundary clear. The hosted service validates OTPs. Your application governs abuse, disclosure, orchestration, and evidence. That division is manageable, but only when it is designed before the first send.
Top comments (0)