The constraint changes the design
Short answer: for a US/EU SaaS product, design SMS OTP as a short-lived, single-use challenge with separate limits for sending, verifying, and retrying; treat the phone network as a restricted channel, not as proof that a person is phishing-resistant.
The customer-support scenario makes this concrete. A user is trying to sign in, the first SMS is delayed, and they press "send again" three times. A support agent later sees a bounce or an invalid recipient in the messaging log. The login system must protect the account without turning a carrier delay into a lockout. Integration effort is the decision axis here. The smallest useful system is one whose security rules live in the application, with the SMS transport behind a narrow adapter.
That boundary matters. A sender should never decide whether an OTP is valid. It should only accept a message and return a delivery reference. The verifier owns the challenge state, attempt count, expiration, and replay decision. Keep those facts together.
Small.
How should a secure SMS OTP login flow handle rate limiting and replay protection?
Start with a challenge record keyed by a server-generated challenge ID. Store a hash of the code, not the code itself, and bind the record to the account, normalized phone number, login transaction, and an intent such as sign_in. Give it a short expiration. Mark it consumed in the same atomic operation that accepts a correct code. The verifier should own the security decision; the sender should not.
Use several rate limits because the abuse paths are different:
- Per account and per phone number: limit verification attempts and OTP sends.
- Per IP, device signal, and ASN where available: slow bulk attacks without making one shared office address a universal lockout.
- Per destination prefix and carrier region: watch for SMS pumping and unusual bursts.
- Per challenge: cap wrong guesses and stop accepting the challenge after success or expiry.
A second send should create a new challenge or explicitly supersede the old one. Do not let five valid codes remain live at once. If delivery ordering is uncertain, a server-side generation counter can make the newest challenge the only accepted one. The UI can say that the most recent code is valid; the backend must enforce it. It isn't enough to disable the button in the browser. A delayed message can arrive after the user has requested a second code, and a malicious client can ignore every visual cue. The server needs one authoritative state transition, with the old challenge rejected even when its code is otherwise correct. That is the difference between a helpful interface and an actual replay boundary.
Retry lockout needs two layers. A challenge-level limit is short and strict. An account-level response can become slower after repeated failures, but it should offer a recovery path that does not reveal whether a phone number is registered. A hard lockout with no recovery is not a security design; it is a support queue generator.
Replay protection is boring by design. A successful verification changes the challenge from pending to used. A second request with the same challenge ID and code gets the same generic failure shape as any other invalid attempt. Never return "already used" to an unauthenticated caller. That response leaks state.
The smallest working implementation
Here is the application-side shape I want before choosing a transport. The repository and storage calls are intentionally abstract: the security properties are the point, and the SMS provider should not dictate them. The consumeIfValid operation must be atomic; a read followed by a write is a race.
type OtpChallenge = {
id: string;
accountId: string;
phoneE164: string;
codeHash: string;
expiresAt: number;
attempts: number;
maxAttempts: number;
consumedAt?: number;
};
type VerifyResult =
| { ok: true; accountId: string }
| { ok: false; reason: "invalid_or_expired" };
interface OtpStore {
find(id: string): Promise<OtpChallenge | null>;
consumeIfValid(input: {
id: string;
codeHash: string;
now: number;
}): Promise<{ accepted: boolean; accountId?: string }>;
recordFailure(id: string, now: number): Promise<void>;
}
async function verifySmsOtp(
store: OtpStore,
challengeId: string,
submittedCode: string,
now = Date.now(),
): Promise<VerifyResult> {
const challenge = await store.find(challengeId);
if (!challenge || challenge.consumedAt || challenge.expiresAt <= now) {
return { ok: false, reason: "invalid_or_expired" };
}
if (challenge.attempts >= challenge.maxAttempts) {
return { ok: false, reason: "invalid_or_expired" };
}
const codeHash = await hashOtp(submittedCode);
const result = await store.consumeIfValid({
id: challengeId,
codeHash,
now,
});
if (result.accepted && result.accountId) {
return { ok: true, accountId: result.accountId };
}
await store.recordFailure(challengeId, now);
return { ok: false, reason: "invalid_or_expired" };
}
async function hashOtp(code: string): Promise<string> {
return cryptoHash(code);
}
declare function cryptoHash(value: string): Promise<string>;
The placeholder hash function is a seam, not a cryptographic recommendation. Use a vetted password/secret hashing primitive, compare in constant time, and keep the code out of logs, analytics events, traces, and support dashboards. The example leaves those choices explicit because hiding them behind a neat helper makes copy-paste security worse.
One small trap: a limit checked in the API process is not a limit. Two instances can both observe room for another attempt. Put counters in a shared, atomic store or enforce them in the same transaction as challenge consumption. Then test concurrent verification with the right code twice. Exactly one request should win.
What should US and EU SaaS teams log without leaking the OTP?
Log decisions, not secrets. A useful event includes a pseudonymous account key, challenge ID, destination country, provider delivery reference, coarse client metadata, outcome class, and latency. It does not include the phone number in raw form or the submitted code. Retention and access policy should match the sensitivity of those identifiers and the legal basis for processing them. I am not prescribing a single US/EU policy here; your counsel and data-protection owner need to resolve the regional details.
Separate delivery failure from authentication failure. A carrier rejection, an invalid destination, a timeout, and a wrong code are different operational signals. The caller can receive a deliberately small set of messages, while an internal event stream keeps the distinctions needed for support and abuse response. This prevents a customer-support agent from asking a user to keep retrying a challenge that has already expired.
The same rule applies to email fallback. DKIM authenticates a domain's signed email content; it does not make an SMS OTP safe, and it does not replace challenge replay protection. Authentication factors need their own threat model.
What changes at scale, and when is SMS the wrong choice?
At higher volume, I would add a durable outbox for send requests, idempotency around provider submission, a delivery-status consumer, and dashboards for send-to-verify conversion by country and carrier. I would also test clock skew, duplicate callbacks, delayed messages, SIM-swap signals, number reassignment, and a user who requests a new code while an old one is in flight. Benchmark the complete path, not just the HTTP handler.
NIST SP 800-63B is the useful reality check: PSTN-based out-of-band authentication is a restricted authenticator. That should affect the product decision, especially for admin accounts or systems holding high-value support data. SMS may remain a practical recovery or low-risk login step, but a phishing-resistant authenticator is the stronger choice when the threat model demands it.
The catch is integration effort. Building this state machine yourself gives precise control, but it also makes your team responsible for abuse controls, regional delivery behavior, privacy reviews, and on-call diagnosis. A managed identity flow can reduce glue code; a self-hosted messaging path can give more control and sometimes more operational burden. Your mileage may vary by country and carrier.
Stick with SMS when the user base expects it, the account risk is understood, and you can provide a safe recovery path. Choose a stronger factor when privileged access, phishing resistance, or account takeover impact matters more than the convenience of a text message.
The decision rule is simple: first make the challenge lifecycle correct, then measure delivery and support cost, then choose the transport. The sender is replaceable.
A sloppy verifier is not.













