Short answer: Validating a code is not “check whether the strings match”. A defensible implementation does eight things: hashes the code at rest, binds it to a number and a purpose, consumes it atomically, expires it quickly, caps attempts and rates, compares in constant time, returns one uniform error, and rotates the session on success. Drop any one and there is a path around the code.

The order a correct check runs in
1. Read the request: phone number + scope + submitted code
2. Rate-limit check: recent failures for this number / IP / account
3. Load the active unconsumed challenge for (number, scope)
4. Missing / consumed / expired -> return one generic "invalid or expired"
5. Constant-time compare hash(input + salt) against the stored hash
6. Mismatch -> attempts + 1, and void the whole record at the cap
7. Match -> atomically mark the record consumed
8. Only after a successful consume, issue the session and rotate the session id
The order is itself part of the design: rate limiting comes before comparison, and consumption comes before issuing a session.
The eight rules
1. Never store the code in plaintext
Store hash(code + per-record salt). A six-digit space is small, so a slow hash buys less than people expect; the salt plus a hard attempt cap is what actually protects you. An HMAC under a server-side key works too.
Equally important: no plaintext codes in logs, APM traces, error reporting or support tooling. More than half of real-world code leaks come out through logs, not the database.
2. Bind the code to a number and a purpose
The key for a challenge record should be (number, scope), and scopes must be distinct: login, signup, password change, phone change, payment confirmation.
The classic bug: a code issued during signup is accepted by the password-change endpoint, because the server only checked that the code existed, had not expired and had not been used — never what it was issued for.
3. Single use has to be atomic
UPDATE otp_challenge
SET consumed_at = now()
WHERE id = ? AND consumed_at IS NULL
Treat it as consumed only when one row was affected. Read-then-write lets the same code be redeemed twice under concurrency, and on a multi-node deployment that race will happen. What users see when this is handled correctly is covered in code already used.
4. Keep the window short, and trust only server time
Five minutes is the common value; going past ten is hard to justify. The reasoning is in why do OTP codes expire. Expiry must be evaluated against server time — never a timestamp the client sent.
5. Cap attempts and rate-limit, both
- Attempt cap — at most five tries against one challenge, then void the record. Continuing to return errors without voiding lets an attacker grind slowly.
- Rate limits — separate counters for number, IP and account, with separately tuned windows.
- Limit the send side too. An unthrottled send endpoint is a weapon someone else will point at a third party. See what is OTP bombing.
The user-facing side of this layer is in “too many verification attempts”.
6. Compare in constant time
Use hmac.compare_digest, crypto.timingSafeEqual or your language’s equivalent rather than ==. Extracting a six-digit code through a timing side channel is genuinely hard, but this is a zero-cost correctness habit with no reason to skip it.
7. One error message, and flat response times
Invalid, expired, already consumed, no such record — all return the same message and the same status code. Differentiating them tells an attacker “this number has an account here”, which is textbook account enumeration. Keep the timing close too, so the response duration does not leak the same fact.
8. After a successful check
- Rotate the session id immediately to prevent session fixation.
- Void the other unconsumed challenges for that number so an older code cannot be replayed.
- Write an audit record: timestamp, IP, user agent, scope, outcome.
- Add a second gate for risky actions such as changing the bound phone or withdrawing funds. See what is step-up authentication.
Three things to get right on the generation side
- Use a CSPRNG. Not
rand(), not a timestamp derivation. A predictable code is no code at all. - Six digits is the usual balance between security and usability — the reasoning is in why are OTP codes 6 digits.
- Throttle resends and reuse the same challenge. A second tap on “resend” within a minute should resend the existing code, not mint a new record. Minting creates several simultaneously valid codes.
How this differs from TOTP
An SMS OTP is a one-time challenge the server generates, stores and delivers. A TOTP is a time-window code both sides compute from a shared secret: the server stores the secret rather than the code, tolerates a small clock-drift window, and needs its own replay protection by recording used time steps. The mechanics are compared in TOTP vs HOTP explained.
Worth being clear-eyed about scope: SMS OTP defends against password reuse and credential stuffing (see credential stuffing). It does not defend against SIM swaps or real-time phishing. That is a design choice, not an implementation flaw.
A pre-launch checklist
- Submit the same code twice; the second must fail.
- Submit after expiry; it must fail.
- Use a code issued to number A against number B; it must fail.
- Use a signup-scope code against the password-change endpoint; it must fail.
- After five wrong attempts, even the correct code must fail.
- Submit the correct code ten times concurrently; exactly one must succeed.
- Grep the whole logging pipeline for plaintext codes; find nothing.
- Compare responses for a known and an unknown number; body and timing should look the same.
For a wider set of test angles see the OTP testing checklist, and for automation against a receive-SMS provider see retries and error handling and the SMS verification API integration guide.
Wrapping up
Almost all of an OTP’s security lives outside the comparison. Anyone can write a string equality check. What decides the outcome is how the code is stored, what it is bound to, how many guesses it allows, how fast it dies, whether consumption is atomic, and how little the failure path reveals. Implement those eight in order and a one-time password is finally one-time. Concept background is in what is a one-time password.