Designing a secure SMS OTP login: throttles, lockout, replay
A threat-by-threat design for phone-code login: what Infrai's managed OTP store enforces, what your own database must, and the Postgres schema that holds it.
Most of the security in a phone-code login lives outside the SMS API. The gateway can generate a code you never see, expire it and count wrong guesses — Infrai’s POST /v1/sms/otp and POST /v1/sms/verify pair does exactly that — but throttling per identity, locking an account after repeated failures, binding a code to the session that requested it and capping how much an attacker can spend of your money are all yours to build.
So the useful way to design this is threat by threat, marking who owns each control. Get that table right and the code writes itself; get it wrong and you ship an endpoint that turns a stranger’s script into an entry on your invoice.
The threats, and who holds the control
| Threat | Control | Owned by |
|---|---|---|
| Brute-forcing a 6-digit code | Attempt cap per challenge, short TTL | Gateway (managed OTP) |
| Replaying a code that already worked | Single-use consumption, session binding | You |
| SMS pumping / toll fraud | Per-phone and per-IP send quotas, country allowlist | You |
| Account enumeration via the send endpoint | Identical response for known and unknown numbers | You |
| Credential-stuffing the verify endpoint | Global lockout and a challenge-must-exist precondition | You |
| SIM swap | Step-up for high-risk actions, re-verification delay after a number change | You |
| Code leaking through logs | Never handling the code server-side | Gateway (managed OTP) |
Two rows are free, five are work. That ratio is normal across every provider — verification products move the line a little, they don’t erase it.
Why your own throttle is not optional
There are no X-RateLimit-Remaining or Retry-After headers on these routes. You find the platform ceiling by hitting it and reading the error:
{
"ok": false,
"error": {
"code": "SMS_RATE_LIMIT",
"http_status": 429,
"message": "rate limit exceeded for sms.otp",
"retryable": true
}
}
Reactive-only rate limiting is a real drawback, and it has a design consequence: the platform limit protects the platform, not your balance or your users. If your own counter isn’t the first thing the request touches, the gateway’s limit becomes your rate limit, and by the time it fires you’ve already paid for every message underneath it.
Worse, the verify side charges even when nothing was verified.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/sms/verify" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to": "+14155550142", "code": "000000"}'
{
"ok": true,
"data": {
"verified": false,
"reason": "no_code_issued",
"to": "+14155550142"
}
}
That call costs $0.005 and answers a question about a challenge that was never created. Ten thousand scripted probes is $50 out of your balance for zero attacker progress — annoying rather than catastrophic, but entirely preventable by refusing to call the route unless your own table says a live challenge exists for that number.
A challenge table that enforces the rules
Put the state in the database, not in process memory. A restart must not hand an attacker a fresh attempt budget, and two app instances must not each grant three tries.
CREATE TABLE otp_challenge (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
phone_e164 text NOT NULL,
session_id text NOT NULL,
request_id text,
attempts int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
consumed_at timestamptz
);
CREATE UNIQUE INDEX otp_challenge_live
ON otp_challenge (phone_e164) WHERE consumed_at IS NULL;
CREATE INDEX otp_challenge_recent ON otp_challenge (phone_e164, created_at DESC);
The partial unique index is the quiet hero: one live challenge per number, enforced by Postgres, so a double-clicked button can’t create two.
// otp-guard.mjs — Node 22 ESM, needs `npm i pg`
import pg from "pg";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const SEND_WINDOW = "15 minutes";
const MAX_SENDS_PER_WINDOW = 3;
const MAX_ATTEMPTS = 5;
const TTL_MINUTES = 5;
async function post(path, body) {
const res = await fetch(BASE + path, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => ({}));
if (res.ok && payload.ok !== false) return payload.data ?? payload;
const err = payload.error ?? {};
throw Object.assign(new Error(err.message ?? `HTTP ${res.status}`), { code: err.code ?? `HTTP_${res.status}` });
}
export async function challenge(userId, phone, sessionId) {
const recent = await pool.query(
`SELECT count(*)::int AS n FROM otp_challenge
WHERE phone_e164 = $1 AND created_at > now() - interval '${SEND_WINDOW}'`,
[phone],
);
// Same answer either way: never reveal whether the number is on file.
if (recent.rows[0].n >= MAX_SENDS_PER_WINDOW) return { status: "sent" };
await pool.query(`UPDATE otp_challenge SET consumed_at = now() WHERE phone_e164 = $1 AND consumed_at IS NULL`, [phone]);
const data = await post("/v1/sms/otp", { to: phone, template: "login_code_en" });
await pool.query(
`INSERT INTO otp_challenge (user_id, phone_e164, session_id, request_id, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '${TTL_MINUTES} minutes')`,
[userId, phone, sessionId, data.request_id ?? null],
);
return { status: "sent" };
}
export async function answer(phone, sessionId, code) {
const live = await pool.query(
`SELECT id, attempts, session_id FROM otp_challenge
WHERE phone_e164 = $1 AND consumed_at IS NULL AND expires_at > now() FOR UPDATE`,
[phone],
);
const row = live.rows[0];
if (!row) return { ok: false, reason: "no_live_challenge" };
if (row.session_id !== sessionId) return { ok: false, reason: "session_mismatch" };
if (row.attempts >= MAX_ATTEMPTS) {
await pool.query(`UPDATE otp_challenge SET consumed_at = now() WHERE id = $1`, [row.id]);
return { ok: false, reason: "locked" };
}
await pool.query(`UPDATE otp_challenge SET attempts = attempts + 1 WHERE id = $1`, [row.id]);
const data = await post("/v1/sms/verify", { to: phone, code });
if (!data.verified) return { ok: false, reason: data.reason ?? "wrong_code" };
await pool.query(`UPDATE otp_challenge SET consumed_at = now() WHERE id = $1`, [row.id]);
return { ok: true };
}
console.log(await challenge("usr_9931", "+14155550142", "sess_a17f"));
console.log(await answer("+14155550142", "sess_a17f", "000000"));
await pool.end();
Four properties fall out of that shape. A verify call can’t reach the API without a live row, so the half-cent tax has a ceiling. consumed_at is set the moment a code succeeds, which is what makes replay impossible even inside the TTL. session_id binds the challenge to the browser that asked for it, so a code phished into a different session doesn’t work. And the send path returns {status: "sent"} for throttled and unknown numbers alike — enumeration gets you nothing.
Keep the TTL short. Five minutes is plenty for a text that usually lands in under 10 seconds, and every extra minute is extra window for a phishing relay.
Lockout wants a ladder rather than a cliff. Five wrong digits inside one challenge should end that challenge, not the account; three ended challenges from the same number inside an hour should push the next send out by 15 minutes; a fourth should require a support path or an alternate factor. Permanent lockout on a login factor is a denial-of-service primitive you hand to anyone who knows a customer’s phone number, which is why the counter belongs on the challenge and the number, and the escalation belongs on time rather than on a flag someone has to clear by hand.
Add a country allowlist on day one, before anyone attacks you. Toll fraud is overwhelmingly a story about premium-rate ranges in countries you’ve never sold to, and a five-line prefix check in front of POST /v1/sms/otp removes most of the attack surface for a product that only serves the US and the EU. Widen it deliberately when sales ask, and log every rejection so you can tell a real expansion request from a probe.
Watch the money, because that’s where pumping shows first
An artificial-traffic attack looks like a spend anomaly long before it looks like a security event. This read is free:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The response breaks spend down by capability, so sms.otp climbing 40x overnight while signups stay flat is your alarm. Wire that to a threshold and a text to yourself — the same key sends it.
For the record: SMS OTP costs about $0.007475 per message and verify $0.005 per call, verified 2026-07-26 and approximate for the send. New accounts get $2 of trial credit, roughly 267 messages. Rates drift downward and campaigns run, so the number you read today may well be lower.
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Honest boundaries
Infrai’s SMS surface has no inbound route without an inbound-capable vendor configured, so reply-to-confirm designs aren’t buildable, and there’s no risk-scoring layer that spots a pumping pattern for you. If your threat model includes organised toll fraud, Twilio Verify ships fraud controls and geo rules as a product, and Vonage offers similar country-level policy — buying that is a defensible call.
What you get by staying on one key is that the challenge table’s database, the rate-limit counters, the alert email, the error tracker that catches a verify storm and the usage query above are one account and one bill. If you need voice or WhatsApp as a second factor channel, you’d be better off with a dedicated verification vendor; if you need phone codes plus the rest of a backend, this is the smaller pile of moving parts.