Short answer: design a secure SMS OTP login flow with short-lived, single-use challenges, rate limits on sends and guesses, bounded retry and lockout, and an atomic consume step for replay protection. SMS can be a convenience or recovery factor; it is a poor fit for privileged actions that need phishing resistance.
I run a one-person SaaS and ship weekly. My test for an authentication control is risk reduced per engineering hour. Carrier delivery is undifferentiated work. The state machine, retry boundary, and audit trail are mine.
What should a cross-border SaaS decide before sending an OTP?
Start with a challenge record. It binds a random six-digit secret to a normalized account subject, the purpose (login), an expiry, an attempt budget, and a lifecycle state. Store a keyed digest, not the plaintext. NIST SP 800-63B says an out-of-band secret should have at least six decimal digits, be valid for no more than ten minutes, and be accepted only once. Five minutes and five attempts are reasonable starting configuration, not universal laws.
The first decision is scope. A code for signing in must not authorize changing a phone number, adding a payout account, or disabling a stronger factor. Give each action its own purpose and challenge. The second decision is identity normalization: canonicalize phone numbers, keep account identifiers stable, and do not let formatting differences create separate budgets.
Ship less.
US and EU coverage changes the delivery boundary, not those security invariants. Consent, sender registration, retention, message wording, and data-processing locations may differ by market. I keep those policies in the adapter and ask qualified counsel about the countries I actually serve. I'm not sure why messaging policy so often leaks into verification code, but separating it keeps the security tests legible.
How do rate limits, retry budgets, lockouts, and replay checks fit together?
Use layered limits because every single key has a bypass. Meter challenge starts by subject, normalized phone number, source IP, and a broader abuse bucket. Meter verification guesses on the challenge itself. An IP-only rule can punish an office or mobile carrier; a phone-only rule can be evaded by rotating numbers. Ceilings belong in configuration and should be tuned against false-positive data. Your mileage may vary.
Resending and guessing are different operations. A resend may deliver the still-live challenge, but it must not extend its deadline or leave two valid secrets. A wrong guess consumes the attempt budget. When the budget reaches zero, mark the challenge locked; never silently reactivate it. Apply the send limits before queueing or dispatching a message, or an attacker can spend money even when every request will later fail.
The verification write needs a compare-and-set (or transaction) that checks active, checks expiresAt, compares the digest, decrements attempts on failure, and changes the state to consumed on success. Session creation follows the commit. A read followed by a later update is a replay window when two requests arrive together.
Here is the contract in TypeScript. The map is only a teaching repository; production persistence must provide the same atomic conditional write. I keep the example deliberately boring because the difficult part is the write boundary: the datastore must decide, in one operation, whether this exact challenge is still active, whether the digest matches, and whether the resulting state is terminal. That operation also needs a clear result for the caller, so a timeout can be retried without guessing whether a session was issued.
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
type State = "active" | "consumed" | "locked" | "expired" | "superseded";
type Challenge = {
id: string;
subject: string;
purpose: "login";
digest: Buffer;
expiresAt: number;
attemptsLeft: number;
state: State;
};
const pepper = Buffer.from(process.env.OTP_PEPPER ?? "", "utf8");
const records = new Map<string, Challenge>();
function digest(id: string, code: string): Buffer {
return createHmac("sha256", pepper).update(`${id}:${code}`).digest();
}
export function issue(id: string, subject: string, now = Date.now()) {
const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
const challenge: Challenge = {
id,
subject,
purpose: "login",
digest: digest(id, code),
expiresAt: now + 5 * 60_000,
attemptsLeft: 5,
state: "active",
};
records.set(id, challenge);
return { challenge, code };
}
export function verifyAndConsume(id: string, code: string, now = Date.now()) {
const current = records.get(id);
if (!current || current.state !== "active" || now >= current.expiresAt) {
return { ok: false, reason: "invalid_challenge" } as const;
}
const matches = timingSafeEqual(digest(id, code), current.digest);
if (!matches) {
current.attemptsLeft -= 1;
if (current.attemptsLeft === 0) current.state = "locked";
return { ok: false, reason: "invalid_code" } as const;
}
current.state = "consumed";
return { ok: true, subject: current.subject } as const;
}
One limitation of the sketch is exposing the map as the source of truth across processes. I avoid that in the real design by putting the conditional transition in a shared transactional store. I also make the delivery decision idempotent: a queue retry for the same challenge and resend generation reads the prior decision instead of sending again. Small boundary. Big effect.
Which failure cases deserve integration tests?
Unit tests prove arithmetic. Concurrency and delivery need integration tests.
| Failure mode | Invariant | Test |
|---|---|---|
| Start flooding | Check shared limits before delivery | Exceed subject, phone, IP, and abuse-bucket ceilings |
| Guess racing | Decrement attempts atomically | Race wrong codes against the final attempt |
| Duplicate resend | One decision per challenge generation | Run the same queue job twice |
| Multiple live codes | One active challenge per subject and purpose | Issue and resend concurrently |
| Replay | Consume before session creation | Submit one valid code from two clients at once |
| Account discovery | Public failures stay coarse | Compare known and unknown subjects |
I once found a retry multiplier by tracing unique challenge IDs rather than message count. Two workers had accepted the same delivery job after a visibility timeout, so the queue behaved normally while the user received duplicates. The fix was a durable idempotency key derived from challenge ID and resend generation, written before handing work to the adapter. The useful metric became sends per unique challenge. During rollout I tagged the internal event E-OTP-07, then followed it through the queue, adapter, and verification log; that three-hour investigation was more useful than another dashboard of total sends. A week of routing abstraction has to earn its keep.
Return stable public reasons such as invalid_code, invalid_challenge, and rate_limited. Keep detailed causes in internal events. Never put OTP values or full phone numbers in logs or metric labels, and do not reveal whether a subject has an account. Rotate a pre-authentication session identifier after successful consumption. I still review those logs after each weekly release; I'm not sure which edge case will appear next, but a coarse external response keeps the blast radius small.
When is SMS the wrong choice for this login design?
The catch is the factor. SMS is not suitable when a stolen phone number, SIM-swap exposure, or phishing-resistant assurance is outside your risk tolerance. Use a phishing-resistant authenticator for administrator access, payment controls, and other high-impact actions; retain SMS only for a deliberately limited recovery or convenience path.
An application-owned state machine gives precise lifecycle control, but it makes you operate atomic storage, throttles, delivery idempotency, and monitoring. A managed verification workflow reduces carrier plumbing, but its state and test fixtures can constrain migration and incident analysis. A second delivery route adds resilience only when both routes share one idempotent send decision; blind failover can double-send.
For a small team, I start with one adapter, a shared challenge table, and a manual recovery runbook. I add regional routing or adaptive friction when delivery and abuse data justify the maintenance. Revenue per hour matters, and the simplest design that preserves one-time use is usually the design I can review every week.
References
- NIST SP 800-63B, Digital Identity Guidelines: Authentication and Lifecycle Management: https://pages.nist.gov/800-63-3/sp800-63b.html
- RFC 6376, DomainKeys Identified Mail (DKIM) Signatures: https://datatracker.ietf.org/doc/html/rfc6376
Top comments (0)