Email OTP security: what to get right
The common security gaps in email OTP implementations — code strength, expiry, rate limiting, constant-time comparison, user enumeration, and logging — and how to address each one.

Email OTP security is not just "does the code run" — it is whether the whole flow has exploitable gaps. These are the most common design problems and how to address them.
Code strength
Six decimal digits give a million possibilities. Combined with rate limiting, brute-force is not viable in practice. For higher-sensitivity operations (step-up authentication for financial actions, for example), use eight digits or an alphanumeric code.
Do not derive codes from timestamps, user ID hashes, or any predictable sequence. Use a cryptographically secure source: crypto.randomBytes in Node.js, secrets.token_digits in Python.
Expiry
Set a hard expiry — typically 5 to 15 minutes — stored in the database or as a Redis TTL, not derived from "time since sending." Delete the code immediately after a successful verification. Without this, the same code can be used more than once.
Rate limiting
Two layers minimum:
- Send rate: one new code per address per 60 seconds.
- Attempt limit: after 3–5 wrong guesses against any single unexpired code, invalidate it and require a new send.
Without an attempt limit, an attacker can try all one million possibilities against a live code without triggering a lockout.
Constant-time comparison
Use constant-time comparison, not string equality. Ordinary string == returns false at the first differing character; the tiny timing difference is measurable under load and can be used to infer the code prefix.
Node.js: crypto.timingSafeEqual(Buffer.from(input), Buffer.from(stored))
Python: hmac.compare_digest(input, stored)
Prevent user enumeration
The verification endpoint should return the same response whether the address does not exist in your system or the code is just wrong. A distinct "address not found" response lets an attacker use the OTP endpoint as an account enumeration oracle. Return "code incorrect or expired" in both cases.
Transport
Send over HTTPS so the code is not exposed in transit. Email itself is not end-to-end encrypted, but that is a property of the email protocol rather than a design choice you can fix at the OTP layer — mitigate it by keeping expiry windows short.
Logging
Do not log OTP values in plaintext. "Sent OTP to xxx@example.com" is enough for debugging; the code value itself should not appear in logs.