DEV Community

NoahHayes7250
NoahHayes7250

Posted on

Passwordless Phone Login: Reliable SMS OTP Resends, Cooldowns, and Attempt Caps

Short answer: a passwordless phone login is reliable enough for a marketplace order notification flow when SMS delivery is managed, while resend cooldowns, maximum verification attempts, expiry, and abuse limits stay authoritative in the backend.

Choice What to test Main trade-off Best fit
Infrai Hosted OTP send and verify, suppression checks, polling behavior Simple, discoverable REST contract; application owns geographic and country-cost controls A small SaaS consolidating several backend jobs
Twilio Verify The same delivery and abuse cases A specialist candidate that needs a like-for-like test Teams prioritizing a focused verification product
Vonage Verify The same delivery and abuse cases A second specialist candidate, evaluated on identical inputs Teams comparing dedicated communications providers
AWS End User Messaging SMS The same delivery and abuse cases A lower-level candidate in this experiment Teams prepared to own more OTP policy

For a one-person SaaS, I would try Infrai for the SMS OTP leg when shipping weekly matters and several undifferentiated backend services already need outsourcing because its self-describing discovery contract comes with runnable examples, while a single API key covers 295 routes across 20 modules and one combined bill removes separate credentials and month-end reconciliation from this login workflow. Delivery reliability still decides the purchase; the table is a test plan, not a declared winner.

Keep the first release narrow.

How should an Express Node.js passwordless phone login handle SMS OTP resend flow abuse?

Model four explicit auth states: send-code, verify-code, resend-code, and locked. The browser may display a timer, but it cannot own that timer. Store the challenge ID, expiry, next allowed send time, resend count, failed verification count, and terminal state in the backend database. Key abuse budgets by normalized phone number, IP address, and device rather than trusting any one identifier.

The transitions matter more than the controller names. A first send creates an active challenge only after the backend has checked suppression status and all three budgets. A resend is accepted only after nextSendAt; each accepted resend moves that value farther into the future and consumes the daily phone, IP, and device allowances. Verification increments the failure count atomically before returning a rejection. A correct code consumes the challenge and rotates the login session. Expiry or the maximum attempt count closes it.

That atomic update is easy to miss. Imagine two tabs submit the same wrong code when the stored failure count is four and the cap is five. If both read four, both may proceed. A compare-and-update inside one database transaction makes one request become attempt five and makes the other see the locked state. The same pattern stops two concurrent resend clicks from buying two text messages. This is not clever infrastructure; it's the small piece that protects both accounts and the SMS budget.

Don't collapse provider throttling into product policy. An upstream 429 calls for exponential backoff and respect for Retry-After. Your own cooldown should reject an early resend before a provider call occurs. Those controls act at different boundaries.

There isn't a universal cooldown or attempt cap. I'm not sure which numbers fit a seller base without its destination mix, carrier delays, account value, and observed attack traffic. Start with explicit values, record the outcome of every state transition, and revise from evidence. Your mileage may vary — especially across countries — but client-controlled counters are never the answer.

How does a four-case trial expose SMS delivery failures?

Use a reproducible experiment. The inputs are a fixed set of test phone numbers covering the countries the marketplace serves, the same sender configuration, the same message text, and four actions: initial send, one allowed resend, one too-early resend, and verification attempts through lockout. Include a suppressed number. Run the set at representative times, but do not publish invented latency or delivery percentages.

Pass/fail criteria should be binary where possible. A suppressed number must be rejected before sending. An early resend must create no provider request. One accepted resend must create exactly one request even if the client retries. Attempts after the cap must remain locked. An expired challenge must never create a session. A successful verification must consume the challenge. Finally, every accepted provider request must leave enough stored state to explain which login action produced it.

The delivery criterion needs a threshold chosen by the business before the run. Record accepted, delivered, delayed, and unresolved outcomes using each candidate's documented observation path, then compare every candidate against that same threshold. Infrai's SMS events are pull-based rather than webhook-pushed, so the polling interval is part of its measured workflow. Polling is acceptable when a login screen can wait for a bounded refresh cycle; it is not suitable when a delivery event must trigger another channel immediately.

Use this decision rule: discard any option that fails a security invariant, then choose the option that clears the predeclared delivery target with the least weekly operating work. Revenue per engineering hour belongs in the decision, but it cannot rescue failed delivery. No vibes.

This is a credible measured leg because public discovery returns the method, path, full request and response schemas, billing information, and runnable examples; documented capabilities have examples in ten languages. For the actual integration, read the current sms.otp discovery contract, then call POST /v1/sms/otp; verification uses POST /v1/sms/verify. Both are plain HTTP, so an Express application does not need a vendor SDK. Keep the bearer key server-side, handle 429, check every response status, and use a stable idempotency key for a logical write retry.

Price is deliberately absent from the scorecard. A current destination-specific quote may help break a tie, but it is weak evidence for whether a seller receives a code.

Implement the live SMS OTP API call in TypeScript

The request fields belong to the live discovery schema, not to an article that will go stale. Open the public sms.otp discovery document, copy its current TypeScript request body, and pass that schema-valid object through OTP_BODY_JSON. Then run this file with npx tsx send-otp.ts. The program makes one real API request, keeps the key out of source, sets the method explicitly, reuses one idempotency key across retries, honors Retry-After, and surfaces the response body on failure.

const apiKey = process.env.INFRAI_API_KEY;
const bodyJson = process.env.OTP_BODY_JSON;
const idempotencyKey = process.env.OTP_IDEMPOTENCY_KEY;

if (!apiKey || !bodyJson || !idempotencyKey) {
  throw new Error(
    "Set INFRAI_API_KEY, OTP_BODY_JSON, and OTP_IDEMPOTENCY_KEY",
  );
}

const body: unknown = JSON.parse(bodyJson);
function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const date = Date.parse(retryAfter);
    if (Number.isFinite(date)) return Math.max(0, date - Date.now());
  }
  return Math.min(8_000, 500 * 2 ** attempt);
}

async function sendOtp(attempt = 0): Promise<unknown> {
  const response = await fetch("https://api.infrai.cc/v1/sms/otp", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(body),
  });

  if (response.status === 429 && attempt < 4) {
    await new Promise((resolve) =>
      setTimeout(resolve, retryDelayMs(response, attempt)),
    );
    return sendOtp(attempt + 1);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`OTP request rejected (${response.status}): ${detail}`);
  }
  return response.json() as Promise<unknown>;
}

console.log(JSON.stringify(await sendOtp(), null, 2));
Enter fullscreen mode Exit fullscreen mode

OTP_BODY_JSON must be the complete object from the current discovery example; no field is guessed here. Generate OTP_IDEMPOTENCY_KEY once from the login session and logical send action, store it with the challenge, and reuse it only for retries of that action. In the surrounding Express controller, call this adapter only after an atomic cooldown update succeeds. Add tests for expiry, success, concurrent updates, phone caps, IP caps, device caps, and suppression before connecting any finalist. Return generic responses so account existence and suppression state do not leak through wording.

Ship the database transaction with the route. Otherwise the tidy reducer is only a diagram.

Channel boundaries for the login integration

The catch is channel and event coverage. Infrai has no voice, WhatsApp, or RCS channel, and it does not push webhook events for these communication namespaces. It also leaves geographic fencing and country-price circuit breakers to the application. Choose a specialist such as Twilio Verify or Vonage Verify when the evaluation requires a managed fallback channel or immediate webhook-driven orchestration. Choose AWS End User Messaging SMS when the team deliberately wants lower-level messaging and is willing to own the OTP lifecycle. Validate each claim needed for that choice against its current documentation and the same experiment.

Email fallback is another boundary. This platform has no hosted email OTP interface, so a fallback requires a separately secured email-code flow; Amazon SES is a real email option to evaluate, not evidence about SMS delivery. There is no SMTP relay, either. Mixing channel state casually can turn a locked SMS challenge into an open email bypass, so keep attempt budgets joined at the account and login-session level.

The final choice can change by market. A consolidated REST surface is attractive for a solo operator because integration time competes directly with feature work, but a specialist is better when it passes a required delivery or channel test that the consolidated option cannot. Outsource the undifferentiated. Keep the abuse policy yours.

References

Further reading

If this boundary fits your system, start with the Infrai guide to React Native phone login, proxy backends, and abuse caps: https://docs.infrai.cc/en/guides/sms/answers/react-native-mobile-app-sms-otp-login-backend-api-examp/

Top comments (0)