Most OTP bugs we see are not in the code generation. They are in what the caller forgot to check before verifying it.
The usual shape: a library gives you generate() and verify(). Everything protecting that pair — how soon a user can request a new code, how many wrong guesses before the code dies, how long the account stays locked — is left to the endpoint. So it gets written once in the login handler, differently in password reset, and not at all in the phone-change flow someone shipped later.
Each of those is a separate brute-force window, and none of them look like bugs during review.
Our take, after years on flows where a wrong code means money or identity: a one-time code is not a string. It is a small state machine that knows when it was last sent, how many attempts it has absorbed, and whether it is currently locked. If that state lives inside the library, every caller inherits the protection whether or not the developer was thinking about it that day. If it lives in the caller, protection is a convention — and conventions decay per endpoint.
The practical test: add a new OTP-protected action to your product. If you have to remember to re-implement cooldown and attempt limits, they are in the wrong place.
We put ours in a small Python package, otpguard, open on GitHub under shipmindlabs — resend cooldown, attempt counting and lockout are part of the verification result, not a separate thing to wire up.
Where does the attempt counter live in your auth stack right now — the code, the session, or the endpoint?