Why we killed the password
Passwords are the weakest link in almost every breach, and the worst part of every sign-up flow. So we removed them. Kool uses passwordless sign-in end to end: nothing to remember, nothing to leak, nothing for us to store badly.
Removing the password does not remove the problem, though. It moves it onto the code you send instead, and a one-time code is only as good as the four or five boring decisions around it. Here they are.
The code is stored as a hash, not as itself. A row in the codes table holds a SHA-256 of what we sent, the number of attempts against it, and an expiry. If someone reads that table they have not read anybody's sign-in code.
It expires in ten minutes and dies after five wrong guesses. Both numbers matter for the same reason: a six-digit code is a million possibilities, which is a lot for a person and nothing at all for a machine, so the value of the code has to be bounded in time and in attempts rather than in length.
Requests are limited to five per five minutes and verifications to ten, and those limits are enforced in a place that can actually count rather than in a per-instance map. That distinction is its own post, and we wrote it: on a platform that runs your code in hundreds of places at once, an in-memory counter is not a quota.
If the account has an authenticator app enrolled, that second factor is checked before the emailed code is consumed. This one is small and entirely about how it feels to be the person signing in. Fumble your authenticator, and if we had already burned the email code you would be sent back to your inbox for a new one, punished for our bookkeeping. Checking first means a mistyped six digits costs you a retry and nothing else.
The bug we want to record is subtler than any of those, because it is the kind that only exists once you let people have more than one address.
A Kool account can be signed into by an address that is not the one the account is filed under. The request route resolved that address before storing the code, so the code was filed under the account's real address. An earlier version of the verify route looked the code up under whatever the person had typed. When those two disagree, the lookup finds nothing, and the message the user sees is "code expired, request a new one" for a code that is neither expired nor wrong. They request another. It happens again. Nothing in the logs looks like a failure.
Both routes now resolve the address the same way, through the same function, and the reason is written above both of them so the next person to touch one knows they have to touch the other. Two places deciding the same thing independently is a bug even when they currently agree, because agreement is not a property either of them is enforcing.