OMAR
Field NotesCV
Email OTP That Is Not Security Theatre
← All Notes
Security28 April 2026 · 6 min read

Email OTP That Is Not Security Theatre

One-time codes are easy to add and easy to get wrong. Six digits with no rate limit, no expiry, and a leaky error message is worse than no second factor at all.

Email one-time codes are the most common second factor on small business dashboards, because they need no app, no hardware, and no user education. They are also easy to implement in a way that provides almost no security while looking like it does.

Here is what separates a real implementation from theatre.

Generate with a real random source

Math.random() is not a cryptographic generator. It is seeded and predictable, and a six-digit code from it is a guess away from being derived.

import { randomInt } from 'node:crypto'
const code = String(randomInt(0, 1_000_000)).padStart(6, '0')

One import. No excuse for anything else.

Store the hash, not the code

An OTP is a credential for its lifetime. If your database is read — by a backup, a log, or an injection — plaintext codes are live account access for everyone currently authenticating.

const hash = await bcrypt.hash(code, 10)
await db.otp.create({
  data: { userId, hash, expiresAt: addMinutes(new Date(), 10), attempts: 0 }
})

Short expiry, single use, and actually deleted

Ten minutes is generous. Fifteen is the maximum I would defend. Beyond that you are widening the window in which an intercepted email is an account takeover.

More important: the record must be consumed on success, not merely marked as used. And a new request must invalidate the previous code, otherwise every resend leaves another valid credential alive in the system.

Rate limit both directions

This is where most implementations fall down. Two separate limits are required, and they stop different attacks.

Verification attempts. Six digits is a million possibilities, which sounds large until an unlimited endpoint tries them at a thousand a second. Five attempts, then the code is destroyed and a new one must be requested.

Send requests. Without a limit, your endpoint is a free email cannon aimed at any address an attacker supplies. Rate limit per account and per address, and add a short cooldown between sends.

if (otp.attempts >= 5) {
  await db.otp.delete({ where: { id: otp.id } })
  return res.status(429).json({ error: 'Too many attempts. Request a new code.' })
}

Compare in constant time

After hashing, compare with a timing-safe function. Byte-by-byte comparison returns fractionally faster on an early mismatch, and that difference is measurable over enough requests.

bcrypt.compare handles this. If you are comparing raw values anywhere, use crypto.timingSafeEqual.

Do not leak account existence

No account with that email tells an attacker which addresses are worth attacking. The response should be identical whether or not the account exists — same message, same status, and ideally similar timing.

If an account exists for that address, we have sent a code.

Bind the code to the session that requested it

A code issued in one browser should not be redeemable in another. Tie the OTP record to the session or login attempt that created it, so an intercepted code alone is not enough — the attacker also needs the in-progress session.

Write the email like a security message

The email is part of the control. It should state the code, its expiry, what it is for, and what to do if the recipient did not request it. No marketing, no images, no tracking pixels, and no clickable login link that turns your second factor into a phishing template.

Know what it does not protect against

Email OTP defends against credential stuffing and reused passwords, which is the realistic threat for a staff dashboard. It does not defend against a compromised mailbox, and it is weaker than TOTP or a passkey against a determined attacker.

For an interior design company's staff panel, that trade-off is correct — the alternative was no second factor at all, because nobody was going to install an authenticator app. Choosing a control your users will actually use is part of the engineering.

The measure of a second factor is not whether it exists. It is whether an attacker with the password still fails.

The checklist

  • Cryptographic randomness
  • Hashed at rest, consumed on use
  • Ten minute expiry, previous codes invalidated on resend
  • Attempt limit that destroys the code
  • Send limit per account and per address
  • Constant-time comparison
  • Identical responses regardless of account existence
  • Bound to the originating session
  • A plain, honest email
2FAAuthNode.js

Need this built properly?

I build secure, fast, bilingual platforms for clients across Egypt, Saudi Arabia, the UAE and Kuwait.

Keep Reading