DEV Community

Anish Hajare
Anish Hajare

Posted on

Building Safer Email OTP Verification in Node.js: Expiry, Retries, and Lockouts

Email verification sounds simple.

And at first, it kind of is. 😄

You generate a code, send it to the user, and compare it when they type it back in.

Done.

Except... not really.

The moment I tried to make OTP verification behave more like something a real app could rely on, the hidden problems showed up fast:

  • What if someone brute-forces the OTP?
  • What if they keep requesting new codes?
  • What if the OTP expires?
  • What if email delivery fails?
  • How do you balance security with usability?

So in this project, I built an email verification flow with OTP expiry, failed-attempt tracking, temporary lockouts, and resend protection.

And honestly, OTP ended up being more interesting than I expected.


🤔 Why Basic OTP Flows Are Not Enough

A simple OTP flow usually looks like this:

  • Generate a code
  • Store it
  • Send it
  • Compare it later

That works for demos.

But if you stop there, you leave a lot exposed:

  • Users can guess the code repeatedly
  • They can spam resend
  • Expired codes may still linger
  • Failed delivery can create broken account states
  • The OTP itself becomes a small attack surface

That's why I wanted the system to do more than just "send a 6-digit code."


🧠 What My OTP Flow Does

When a user registers:

  1. A 6-digit OTP is generated
  2. The OTP is hashed before storage
  3. An expiry time is set
  4. The code is emailed to the user
  5. The account stays unverified until the OTP is confirmed

And on top of that, the system also includes:

  • Attempt counting
  • Temporary lockout after too many failures
  • A resend cooldown
  • Fresh OTP creation on resend
  • Cleanup of expired records over time

🔐 Why I Hash the OTP

This was a simple but important decision.

Instead of storing the OTP in plain text, I store a hash of it.

function hashValue(value) {
  return crypto.createHash("sha256").update(value).digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

That means even if someone gained database access, the raw OTP would not just be sitting there in readable form.

It follows the same basic principle as password hashing:

  • Sensitive secrets should not be stored in plain text
  • The database should not become an easy source of abuse

⏳ OTP Expiry Matters

A verification code should not live forever.

So each OTP gets an expiration time.

const expiresAt = new Date(Date.now() + config.OTP_EXPIRY_MINUTES * 60 * 1000);
Enter fullscreen mode Exit fullscreen mode

That helps in a few ways:

  • Limits how long an OTP is useful
  • Prevents old codes from hanging around forever
  • Makes the verification process more predictable

I also added a TTL index on the OTP model.

That means expired OTP records become eligible for automatic cleanup by MongoDB in the background.

That detail is worth phrasing carefully: TTL cleanup is helpful, but it is not guaranteed to happen at the exact second the OTP expires.

So the app still checks expiry directly when verifying the code.


🚫 Failed Attempts and Temporary Lockout

This was one of the most important protections in the flow.

If someone enters the wrong OTP repeatedly, the system tracks the number of failed attempts.

After enough bad attempts, the OTP is temporarily locked.

Here's the core idea:

if (otpDoc.otpHash !== hashValue(otp)) {
  otpDoc.attemptCount += 1;

  if (otpDoc.attemptCount >= config.OTP_MAX_ATTEMPTS) {
    otpDoc.lockedUntil = new Date(
      Date.now() + config.OTP_LOCKOUT_MINUTES * 60 * 1000
    );
  }

  await otpDoc.save();
  throw new AppError(400, "Invalid OTP");
}
Enter fullscreen mode Exit fullscreen mode

This helps reduce OTP guessing.

Because without limits, a 6-digit code field becomes a lot easier to abuse.


🔁 Resend Protection Was Surprisingly Important

At first, resend felt like a tiny feature.

Then I thought about it a little longer and realized it needed rules too.

Without controls, users or attackers could:

  • Spam the resend endpoint
  • Flood email delivery
  • Reset the verification flow repeatedly
  • Turn a useful feature into an abuse point

So I added a resend cooldown in the OTP flow.

That means a new OTP cannot be sent immediately over and over again.

And when a new OTP is created, the previous OTP record for that user is deleted first, so only the latest code remains valid.

That also means resend creates a fresh OTP record, which naturally resets previous attempt and lock state.


🛡️ OTP Resend Protection vs Rate Limiting

This is a subtle distinction, but I think it matters.

The resend flow is protected in two different ways:

  • A resend cooldown specific to OTP behavior
  • A generic route-level rate limiter

Those are related, but they are not the same thing.

  • The cooldown is part of the OTP business logic.
  • The route limiter is a broader per-IP request limit.

I liked having both, because they protect the flow from slightly different angles.


😅 One Subtle Problem: Failed Email Delivery

Another detail I cared about was what happens if registration succeeds but the verification email fails.

That can create a frustrating state:

  • The user exists
  • The account is unverified
  • The OTP never arrived
  • The signup feels broken

So I chose to roll back registration if sending the verification email fails.

That way the system avoids leaving behind incomplete account states.

It's one of those small details that makes the flow feel much more intentional.


⚖️ Security vs Usability

This was the hardest part of OTP design.

Security says:

  • Expire quickly
  • Lock after repeated failures
  • Limit resends
  • Be strict

Usability says:

  • Don't punish honest mistakes too harshly
  • Give the user time to check email
  • Allow recovery when delivery is delayed
  • Keep the process understandable

So the real challenge was not just adding protections.

It was choosing protections that still felt reasonable for a real user.

That balance is where most of the design thinking happened.


📚 What I Learned

Building OTP verification taught me a few things:

  • OTP flows are small, but not simple
  • Every verification feature creates its own abuse risks
  • Hashing OTPs is worth doing
  • Retry limits and lockouts matter
  • Resend behavior needs guardrails too
  • Security and recovery need to be balanced together

The biggest takeaway?

Email verification is not just a checkbox feature. It's part of your security design.

And once I started thinking about it that way, the implementation got much better.


🎯 Final Thoughts

Before this project, I thought of OTP verification as a small supporting feature.

After building it, I started seeing it as a tiny system of its own with its own rules, tradeoffs, and security concerns.

That was actually one of my favorite lessons from this auth project.

If you're adding email OTP verification to your app, don't stop at "generate and compare code."

Think about:

  • Expiry
  • Retry limits
  • Lockouts
  • Resend protection
  • Failure handling

Because those are the parts that turn a working OTP flow into a safer one.


Have you built OTP verification before? Drop a comment below and ask questions if you have any. I'd love to hear how you handled the edge cases. 💬

Top comments (0)