Treat Email Verification Codes Like Passwords
A 6-digit email code is a password with worse UX expectations and a shorter lifespan - so FLITO stores only its hash, expires it in 15 minutes, burns it after one use, and locks out after 5 wrong tries. Here's the reasoning and what it protects against.
It's easy to treat a 6-digit email verification code as a throwaway - generate it, store it, compare it, delete it. FLITO, a freight-matching marketplace for Nepal, needed real accounts for shippers, truck owners and drivers, and the verification codes gate account creation and password resets. So I built them with the same paranoia I'd apply to a password, because that's functionally what a short-lived numeric code is: a low-entropy secret that a database leak can turn into an account takeover.
Why a 6-digit code deserves password-level treatment
A 6-digit code has a million possible values - far fewer than a password, but the attack surface isn't brute force over the network (that's what rate limiting is for). It's a database leak. If verification codes sit in the database as plain text, a leaked users table doesn't just expose emails and password hashes; it hands over every currently-valid verification code too, and with it, the ability to complete a password reset or account verification for any account whose code hasn't expired yet. The fix is the same one that's been standard for passwords for years: never store the secret, store a hash of it.
Hash it, expire it, burn it after one use
FLITO's codes are random 6-digit values. The API stores only a hash of the code, the same way it stores password hashes - never the code itself, and the API never returns it in a response body either (it's emailed, not surfaced anywhere a client-side log or browser extension could capture it). Each code expires 15 minutes after issuance, works exactly once, and after 5 wrong attempts the account locks the flow out rather than allowing indefinite guessing against the hash.
Every one of those is a separate failure mode closed: no expiry means an old, forgotten code stays a live credential forever; no single-use means a code intercepted once (a shared inbox, a screenshot) stays valid for reuse; no attempt limit means the 1-in-a-million odds of a guessed code stop mattering once an attacker gets to try a million times.
Testing security code without sending a real email
The verification flow is covered by FLITO's test suite the same way the rest of the API is: MongoDB runs in memory for the run, and the email provider is mocked rather than actually called. That matters specifically for this code path, because it's the one place where a hashed, expiring, single-use secret has multiple ways to silently regress - a refactor could accidentally start comparing plaintext, or drop the expiry check, and a test suite that doesn't exercise those branches would never catch it.
None of this is unique to FLITO or even to verification codes specifically - it's the same threat model as a password reset token, an API key, or a session identifier: if it grants access, it gets hashed at rest, scoped in time, and rate-limited on the way in. The lesson I took from building this is to stop mentally filing '6-digit code' under 'temporary, so it doesn't matter' - anything that grants access is a credential, however short-lived it looks.
