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:
- A 6-digit OTP is generated
- The OTP is hashed before storage
- An expiry time is set
- The code is emailed to the user
- 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");
}
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);
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");
}
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)