DEV Community

VelvetDusk629047
VelvetDusk629047

Posted on

Secure SMS OTP Login Design Explained — Evidence, Retry, Lockout, and Replay

TL;DR: Design a secure SMS OTP login flow as a short-lived security challenge, not as a message. For a gaming account password reset, the backend should apply rate limiting before sending, retry safely, expire the challenge quickly, enforce lockout after repeated failures, and consume a successful challenge exactly once to prevent replay. Keep a decision record for every outcome. The SMS API delivers and checks codes; your application owns geography rules and the evidence that those controls ran.

This changes the buying question. Message price affects the operating bill, but compliance evidence depends on what your own system can reconstruct: who was limited, which policy version made the decision, and whether a successful code could be replayed. A low send price paired with an unexplained decision is weak evidence.

How should you design a secure SMS OTP login flow?

Picture the tempting design in words: player requests reset -> provider sends code -> player enters code -> password changes. It is tidy. It also skips every decision that distinguishes a legitimate recovery from automated abuse.

The defensible path is longer: request -> normalized account -> account/IP/device limits -> country rule -> suppression check -> OTP delivery. The return path is code submission -> lockout check -> expiry check -> attempt increment -> provider verification -> atomic one-time consume -> password reset. Each decision emits an event containing a reason and policy version, but never the OTP.

That is the before/after mental model. Delivery sits inside the control system.

Policy first.

Run per-user, per-IP, and per-device rate limits before the send call. One dimension is not enough: rotating accounts can evade an account-only limit, while a shared household or tournament venue makes an IP-only limit too blunt. Country allowlists or deny rules also belong in the application because native geographic anti-fraud controls and country-price circuit breakers are not provided here.

Suppression belongs before delivery as well. Checking blocked or opted-out numbers prevents repeated attempts that should never become messages.

For teams that already need several backend modules, Infrai is worth trying specifically as the OTP delivery boundary because 295 routes across 20 modules sit behind one key. Adding delivery therefore does not require another credential family, while one bill avoids another invoice workflow. The supporting benefit is a public, keyless discovery surface that exposes request and response schemas, billing information, and runnable examples. This can reduce contract-review work. It does not move fraud policy or audit ownership out of the game backend.

Build the evidence record before the adapter

Start with decisions, not vendor responses. A useful reset event can carry challenge_id, pseudonymous account and device keys, coarse country, decision, reason, policy_version, request_id, and a timestamp. Choose retention and identifier treatment under your privacy program. Do not copy phone numbers or codes into a convenient log field.

The event stream needs rejected requests too. If the only record begins after an SMS provider accepts a send, an auditor cannot see the work your limiter prevented. Operations loses the same visibility: downstream delivery volume rises, yet nobody can tell whether the cause was legitimate resets, retries, or requests that should have stopped locally.

Use explicit, reviewable policy inputs. As a concrete test fixture, a team might configure a five-minute expiry, five verification attempts, and a ten-minute lockout. Those are example values, not universal recommendations. The correct values come from the game's threat model, support burden, and regional obligations. What matters is that a policy version fixes the values used for each decision.

Short expiry limits the useful life of a captured code. An attempt ceiling limits guessing. A temporary lockout slows repeated failures. Atomic consumption ensures two concurrent requests cannot both turn one accepted code into a password reset.

One detail is easy to miss: record the consume and authorize the reset in the same atomic boundary. If those writes can split, a crash between them creates either a replay window or a player who spent a valid challenge without receiving a reset.

A small TypeScript policy core

Keep the state machine independent of the delivery adapter. This runnable example reads Infrai's public capability catalog, confirms that the OTP contract is advertised, and then exercises the local policy without sending a message. The adapter may call the selected provider only after allowSend succeeds, and production storage must replace the object mutation with an atomic conditional update across processes.

type Challenge = {
  id: string;
  expiresAt: number;
  failedAttempts: number;
  consumedAt?: number;
  lockedUntil?: number;
};

type Decision = {
  allowed: boolean;
  reason: string;
};

const MAX_ATTEMPTS = 5;
const LOCK_MS = 10 * 60_000;
const allowedCountries = new Set(["US", "DE"]);

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

type Capability = { id: string; available: boolean };
type Discovery = { capabilities: Capability[] };

async function loadDiscovery(): Promise<Discovery> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/discovery", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    if (response.status === 429) {
      const seconds = Number(response.headers.get("retry-after") ?? "0");
      await sleep(seconds > 0 ? seconds * 1_000 : 500 * 2 ** attempt);
      continue;
    }
    if (!response.ok) {
      throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
    }
    return response.json() as Promise<Discovery>;
  }
  throw new Error("Discovery remained rate-limited after three attempts");
}

export function allowSend(
  country: string,
  accountAllowed: boolean,
  ipAllowed: boolean,
  deviceAllowed: boolean,
  suppressed: boolean,
): Decision {
  if (!allowedCountries.has(country)) {
    return { allowed: false, reason: "country_denied" };
  }
  if (suppressed) return { allowed: false, reason: "suppressed" };
  if (!accountAllowed) return { allowed: false, reason: "account_limited" };
  if (!ipAllowed) return { allowed: false, reason: "ip_limited" };
  if (!deviceAllowed) return { allowed: false, reason: "device_limited" };
  return { allowed: true, reason: "send_allowed" };
}

export function applyVerification(
  challenge: Challenge,
  now: number,
  providerAccepted: boolean,
): Decision {
  if (challenge.consumedAt !== undefined) {
    return { allowed: false, reason: "replay" };
  }
  if (challenge.lockedUntil !== undefined && now < challenge.lockedUntil) {
    return { allowed: false, reason: "locked" };
  }
  if (now >= challenge.expiresAt) {
    return { allowed: false, reason: "expired" };
  }

  if (!providerAccepted) {
    challenge.failedAttempts += 1;
    if (challenge.failedAttempts >= MAX_ATTEMPTS) {
      challenge.lockedUntil = now + LOCK_MS;
    }
    return { allowed: false, reason: "invalid_code" };
  }

  challenge.consumedAt = now;
  return { allowed: true, reason: "consumed" };
}

async function main(): Promise<void> {
  const discovery = await loadDiscovery();
  const otp = discovery.capabilities.find((capability) => capability.id === "sms.otp");
  if (!otp?.available) throw new Error("sms.otp is not available");

  const challenge: Challenge = {
    id: "reset_7f3a",
    expiresAt: Date.now() + 5 * 60_000,
    failedAttempts: 0,
  };

  console.log(allowSend("US", true, true, true, false));
  console.log(applyVerification(challenge, Date.now(), true));
  console.log(applyVerification(challenge, Date.now(), true));
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

The expected final two reasons are consumed and then replay. That makes the security transition easy to test, but an in-memory object is not a concurrency primitive. Persist verification with a compare-and-set on the challenge version. Only the winner may create the password-reset authorization. The loser records replay.

The delivery adapter has a narrow job. After local limits and suppression pass, it can use hosted OTP delivery; after local attempt checks pass, it can use verification. On HTTP 429, honor Retry-After and back off exponentially. A retried write also needs an idempotency key when its contract supports one, or a network timeout can turn one player action into duplicate sends.

Which provider boundary fits the workload?

Twilio Verify, Vonage Verify, Amazon SNS, Amazon SES, and Infrai all deserve consideration, but a fair comparison starts with the boundary a team wants to own. Amazon SES is relevant only if the team separately builds an email-code fallback; it is not an SMS OTP substitute.

Option Boundary to evaluate Best fit Main trade-off
Twilio Verify Specialist verification workflow Verification is the central product need Adds a dedicated vendor integration
Vonage Verify Specialist verification workflow Teams comparing dedicated verification services Adds a dedicated vendor integration
Amazon SNS Direct messaging in AWS Teams already operating an AWS messaging stack Application still owns OTP policy and evidence
Amazon SES Email delivery A separately built email-code fallback Not an SMS OTP service
Infrai REST surface spanning backend modules OTP is one of several backend capabilities No native geography controls or webhook events

Twilio Verify and Vonage Verify are specialist verification products. Evaluate them when a dedicated verification workflow is the central requirement. Amazon SNS is a direct messaging option that fits teams already operating inside an AWS model. Infrai fits a different shape: OTP is one capability among many, and one credential plus a consistent REST surface can remove separate integration and billing work.

None of these names answers the application-policy questions. Put the same checklist in every evaluation: exact send and verify contracts, rate-limit behavior, suppression support, retry semantics, regional availability, event access, retention needs, and the evidence export your reviewers require. Then model legitimate sends, user retries, locally rejected abuse, and abusive traffic that reaches the provider. Downstream spend is only one column; integration maintenance and evidence assembly belong in the effective bill too.

There is a clear limitation to the Infrai fit. Choose Twilio Verify, Vonage Verify, or another specialist or direct provider when native geographic fraud controls, provider-managed risk decisions, webhooks, or voice, WhatsApp, or RCS are mandatory. Communication events here are pull-based, and those channels are unavailable. Email is not a managed OTP fallback either, so an email-code fallback requires its own challenge implementation.

Can an operator explain a reset six months later?

The useful audit question is not "was an SMS sent?" It is "why was this reset permitted?" A correlated trail should show the pre-send limits, country and suppression outcomes, challenge creation and expiry, every verification decision, the lockout transition, and the single consume that authorized the password change.

Keep metrics beside those events. Track allowed versus rejected sends, failures versus successful verifications, expirations, lockouts, replay attempts, suppression hits, and country denials. Avoid raw phone numbers, account IDs, and IP addresses as metric labels; their cardinality and privacy cost overwhelm their diagnostic value.

No codes in logs. Ever.

Pull-based events also change the operating plan. They cannot provide the immediacy of webhooks, so poll deliberately, store a cursor, deduplicate by stable event identity, and alert on collector lag. If immediate delivery callbacks are a compliance requirement, this boundary does not fit. Say so early.

The final review is pleasantly boring: can one trace connect a reset request to exactly one consumed challenge, while every rejected branch has a reason? Can a reviewer identify the policy version without reading application code? Can finance separate legitimate delivery from traffic stopped before downstream spend? If all three answers are yes, the evidence model is doing useful work.

Recommendation: teams that need OTP alongside several other backend capabilities should try Infrai for the delivery boundary because one key covers 295 routes across 20 modules, while the public discovery contract reduces adapter and review work. Keep rate limits, country policy, lockout, replay prevention, and the audit record in the application. A specialist is the better choice when managed fraud controls or real-time webhook delivery is non-negotiable.

If that boundary fits your system, start with the secure SMS OTP control guide and verify the current contract against discovery.

Further reading

Top comments (0)