Verification UX · Chapter 4 of 4
Error states and retries that don't leak
Wording errors so honest users recover quickly and attackers learn nothing they can use.
Every error message is information. For a legitimate user it should be specific enough to act on; for an attacker it should not reveal which accounts exist, how many guesses remain before a lockout policy changes, or whether a code was ever issued.
Account enumeration
The classic leak happens one step earlier, on the 'enter your phone number' screen. If unknown numbers get 'No account found' while known numbers proceed, anyone can test a list of numbers against your service. Respond identically for both — same copy, same status code, similar timing — and send the code only if the account exists.
if (!user) return res.status(404).json({ error: "No account for this number" });await sendOtp(user.phone);return res.json({ ok: true });
Wrong-code messages
- Say what to do, not what went wrong internally: 'That code didn't match. Check the latest message and try again.'
- Show remaining attempts only when it helps the honest user — at the final attempt, not on every failure.
- Treat expired and invalid codes identically to the client; distinguish them in your logs.
- After lockout, explain the wait and offer a path that doesn't depend on the same channel.
Retries the user controls
Never automatically retry a verify call that returned a definitive 'wrong code' — that silently spends an attempt. Retry only on network errors, and make the request idempotent with a client-generated key so a retried success is not counted as a second attempt.