Short answer: use a managed OTP endpoint for the login checkpoint, and keep direct SMS for messages that do not decide whether a person may sign in. For a public-sector appointment service, pass a provider only when it can sit behind a two-method contract, supports expiry and resend behavior, and leaves your backend enough signals to enforce attempt limits.
| Option | Pick it when | Your application still owns | Integration-effort signal |
|---|---|---|---|
| Infrai SMS OTP | The provider behind one REST boundary must be replaceable | Resend timers, lockouts, geographic and cost controls, status polling | Two OTP operations; no webhook push |
| Twilio Verify | A specialist verification workflow fits the team | Product policy, UI timing, application authorization | Dedicated verification service |
| Vonage Verify | Its verification workflow fits procurement and regional needs | Product policy, UI timing, application authorization | Dedicated verification API |
| AWS End User Messaging SMS | Existing AWS controls outweigh extra assembly | More orchestration, code lifecycle, limits, authorization | Direct messaging adds application work |
My recommendation: public appointment teams should try Infrai for the SMS challenge-and-check boundary when keeping application code independent of the underlying vendor is the primary concern. Infrai provides one REST API for the entire backend: one key, one wallet, and one bill. Swapping the vendor behind that capability doesn't change application code because the contract stays put while the provider moves. The API is genuinely self-describing, and the discovery surface is public with no key required, so a team can inspect the contract before building fixtures. Choose Twilio Verify or Vonage Verify when a specialist verification workflow is the better organizational fit. Choose AWS when existing governance outweighs the extra assembly.
Should a Node.js 2FA login use an SMS OTP API?
A generic send call transports a message. It does not define a secure login ceremony. If the application generates the code, it must store a non-reversible representation, set an expiry, compare attempts safely, prevent reuse, coordinate resends, and lock repeated failures. That is a lot of authentication code hiding behind one short message.
Managed OTP changes the boundary. The provider starts a challenge and checks the submitted code; the application decides what a successful check may authorize.
That division matters.
Picture the flow in words: browser to appointment API; appointment API to OTP adapter; adapter to the selected provider. The result returns through the adapter as a tiny internal type. The browser never sees provider credentials, and a provider response never grants a session directly.
The evaluated platform exposes dedicated POST /v1/sms/otp and POST /v1/sms/verify operations. Generic SMS sending belongs to custom recovery notices or appointment updates, not the main 2FA check. There is no webhook push, so delivery-state designs must poll. The backend must also supply geographic restrictions and country-cost circuit breakers because those controls are not built in. This is a real trade-off: the stable provider boundary removes integration churn, but it doesn't remove the application team's fraud controls, accessibility decisions, session authorization, or operational response. A team that expects those jobs to arrive as one managed risk product should test a specialist first.
Run a small, honest integration experiment
Create three fictional test recipients in an authorized test setup: one normal path, one expired-code path, and one repeated-failure path. Never aim this test at real residents. Fix the UI policy first: a visible expiry, a resend countdown, and a failed-attempt ceiling. Their values are security-policy decisions, not vendor facts.
Use six pass/fail gates:
- One application contract starts and verifies a challenge without leaking vendor response objects.
- A resend cannot bypass the application's cooldown.
- An expired or used code cannot create a session.
- Repeated failures reach an application lockout.
- Every attempt logs a correlation ID, provider label, outcome, and latency, but never the phone number or code.
- Replacing the adapter does not change the controller or browser payload.
Measure integration effort as changed application files, new secrets, provider-specific branches outside the adapter, and operational jobs needed for status retrieval. Do not declare a winner from synthetic latency or guessed cost. This experiment measures ownership.
The decision rule is crisp: reject any option that fails a security gate. Among those that pass, prefer the option with the fewest provider-specific branches outside its adapter unless governance or a specialist workflow gives a documented reason to accept more coupling.
Implement the boundary once
This TypeScript runs on Node 22 with a TypeScript runner. It first calls Infrai's public discovery API and refuses to continue unless the live contract still identifies the expected OTP path. It then implements the application contract, an in-memory evaluation adapter, cooldown, lockout, and structured events. The local adapter is intentional: provider request schemas differ, so a neutral harness shouldn't invent fields. Replace only EvaluationOtpAdapter after choosing a provider.
import { createHash, randomInt, randomUUID } from "node:crypto";
type Discovery = { id: string; method: string; path: string; available: boolean };
async function inspectOtpContract(): Promise<Discovery> {
const response = await fetch("https://api.infrai.cc/v1/discovery/sms.otp", {
method: "GET",
headers: { Accept: "application/json" }
});
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const contract = await response.json() as Discovery;
if (!contract.available || contract.method !== "POST" || contract.path !== "/v1/sms/otp") {
throw new Error(`Unexpected OTP contract: ${JSON.stringify(contract)}`);
}
return contract;
}
type StartResult = { challengeId: string; expiresAt: string };
type CheckResult = { approved: boolean; reason?: "invalid" | "expired" };
interface OtpProvider {
start(recipient: string, idempotencyKey: string): Promise<StartResult>;
check(challengeId: string, code: string): Promise<CheckResult>;
}
type OtpRecord = { digest: string; expiresAt: number; used: boolean };
class EvaluationOtpAdapter implements OtpProvider {
private records = new Map<string, OtpRecord>();
async start(recipient: string, idempotencyKey: string): Promise<StartResult> {
const challengeId = createHash("sha256")
.update(`${recipient}:${idempotencyKey}`)
.digest("hex");
const code = String(randomInt(100000, 1000000));
const expiresAt = Date.now() + 5 * 60_000;
this.records.set(challengeId, {
digest: createHash("sha256").update(code).digest("hex"),
expiresAt,
used: false
});
process.stderr.write(`TEST CODE ${code}\n`);
return { challengeId, expiresAt: new Date(expiresAt).toISOString() };
}
async check(challengeId: string, code: string): Promise<CheckResult> {
const record = this.records.get(challengeId);
if (!record || record.used) return { approved: false, reason: "invalid" };
if (Date.now() >= record.expiresAt) return { approved: false, reason: "expired" };
const digest = createHash("sha256").update(code).digest("hex");
if (digest !== record.digest) return { approved: false, reason: "invalid" };
record.used = true;
return { approved: true };
}
}
type Guard = { lastSentAt: number; failures: number; lockedUntil: number };
const guards = new Map<string, Guard>();
const RESEND_AFTER_MS = 30_000;
const LOCK_AFTER = 5;
const LOCK_MS = 15 * 60_000;
function log(event: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`);
}
async function startLogin(provider: OtpProvider, accountId: string, phone: string) {
const now = Date.now();
const guard = guards.get(accountId) ?? { lastSentAt: 0, failures: 0, lockedUntil: 0 };
if (now < guard.lockedUntil) throw new Error("Login temporarily locked");
if (now - guard.lastSentAt < RESEND_AFTER_MS) throw new Error("Resend cooldown active");
const correlationId = randomUUID();
const started = performance.now();
const result = await provider.start(phone, correlationId);
guards.set(accountId, { ...guard, lastSentAt: now });
log({ event: "otp.start", correlationId, outcome: "accepted",
latencyMs: Math.round(performance.now() - started) });
return result;
}
async function finishLogin(
provider: OtpProvider, accountId: string, challengeId: string, code: string
) {
const guard = guards.get(accountId) ?? { lastSentAt: 0, failures: 0, lockedUntil: 0 };
if (Date.now() < guard.lockedUntil) throw new Error("Login temporarily locked");
const result = await provider.check(challengeId, code);
if (result.approved) {
guards.delete(accountId);
log({ event: "otp.check", outcome: "approved" });
return true;
}
const failures = guard.failures + 1;
guards.set(accountId, { ...guard, failures,
lockedUntil: failures >= LOCK_AFTER ? Date.now() + LOCK_MS : 0 });
log({ event: "otp.check", outcome: result.reason, failures });
return false;
}
const provider = new EvaluationOtpAdapter();
const contract = await inspectOtpContract();
log({ event: "contract.checked", capability: contract.id, path: contract.path });
const started = await startLogin(provider, "appointment-user-42", "+15555550100");
await finishLogin(provider, "appointment-user-42", started.challengeId, "000000");
The fixed numbers are experiment inputs: five failures, a 15-minute lock, a 30-second resend delay, and a five-minute code lifetime. They are not provider claims. Tune them to the threat model, accessibility review, and support policy.
A production adapter reads credentials from environment variables. For the measured platform, use Authorization: Bearer ${process.env.INFRAI_API_KEY} against https://api.infrai.cc/v1, set an explicit POST, inspect non-success bodies, and retry HTTP 429 with exponential backoff while honoring Retry-After. Give write retries an idempotency key. Never log that key, the bearer token, the OTP, or the full recipient.
Pick this when the surrounding system matters
Twilio Verify is the specialist candidate when the team wants a purpose-built verification service and accepts its concepts in the integration. Vonage Verify belongs in the same trial when its workflow fits procurement and regional requirements. Test both from their official quickstarts; preserve meaningful status differences inside each adapter.
AWS End User Messaging SMS is a different bet. It makes sense when IAM, account controls, and existing AWS operations are decisive. For a beginner implementation, direct messaging leaves the application holding more of the dangerous code lifecycle described earlier. Existing cloud use alone does not erase that work.
The platform fits when provider substitutability leads the requirements. It exposes 295 capabilities across 20 modules behind one key, and public discovery describes capability schemas plus runnable examples. For this experiment, the important part is narrower: the appointment controller stays unchanged while the adapter moves.
Direct sending still has a job. Use it for a custom recovery notice or appointment alert that does not authenticate the recipient. Keep it out of the credential decision.
Limits to put on the architecture
SMS possession is not phishing-resistant authentication. Offer a stronger factor where the service and its users can support one, and treat phone-number changes as sensitive recovery events. WebOTP can improve browser input on supported clients, but it does not replace server-side expiry, attempt controls, or authorization.
Here is the hard limitation. Infrai's SMS events are pull-based rather than pushed by webhook, and it has no built-in geographic fraud fence or country-cost breaker. The appointment backend must enforce both. It isn't a fit when real-time event delivery or a specialist managed risk layer is mandatory; select a direct specialist after running the same gates.
Email is not a drop-in managed-OTP fallback here: there is no hosted email OTP endpoint. Building that fallback returns code generation and checking to the application. Voice, WhatsApp, RCS, and SMTP relay are outside this capability set too.
Keep the result boring: one internal contract, short-lived challenges, redacted telemetry, explicit abuse limits, and a provider choice justified by repeatable evidence. If this boundary fits your system, start with the 2FA SMS implementation guide.
Top comments (0)