The Bug That Started It All
It began as a small bug I found myself while testing: OTP verification kept failing whenever I refreshed the page, opened a new tab, or copied the link to open on another device. Easy to reproduce, and my first instinct was to blame the frontend — check the state management, fix it, done.
I was wrong. What looked like a 10-minute bug forced me to redesign how my entire OTP system stored data — and that redesign process is what uncovered a gap I had no idea was there.
Digging In: The Root Cause Was in the Frontend
The immediate cause was easy to spot. I was passing the email to the verification page like this:
navigate('/verify-otp', { state: { email: formData.email } });
Enter fullscreen mode Exit fullscreen mode
React Router's state only lives in the browser's memory. The moment the page refreshes, or the user opens the link in a new tab, that data is gone — location.state just comes back undefined.
The fix seemed obvious: stop relying on in-memory state, use something that survives a refresh. But before jumping to a solution, I got curious about something else — how do bigger platforms handle this? If I open a verification link from Gmail on my phone, days after requesting it from my laptop, how does that still work?
The answer changed how I thought about the whole problem: don't store the source of truth on the client. The client should only hold a reference — a token — while the actual data, validation, and expiry logic stay on the server.
That single realization is what turned a small frontend fix into a full redesign of my OTP system.
Taking Apart the Old Design
The existing OTP model looked complete enough — it stored the code, creation time, expiry time, and usage status, all in the database:
class OTPVerifications(models.Model):
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
otp = models.CharField(max_length=6)
created_at = models.DateTimeField()
expired_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
Enter fullscreen mode Exit fullscreen mode
The validation looked reasonable too: match the code, check whether it falls within the created_at to expired_at window, mark is_used=True on success. This is a common pattern, and at a glance nothing looked wrong.
But once I started tearing it apart to move toward a token-based approach, three problems surfaced one after another:
- The verify endpoint had no meaningful rate limiting. A 6-digit OTP is only 1 million combinations — without attempt limits, it's brute-forceable.
-
Error messages leaked information.
"User not found"(404) versus"Invalid OTP code"(400) was enough for anyone to figure out which emails were registered in the system. - The generate-OTP endpoint had no protection at all. Nothing stopped it from being called over and over.
That third point is the one I initially brushed off — until I realized it was actually the most exposed part of the whole system.
Why the Generate Endpoint Was the Bigger Risk
It's easy to assume the verify endpoint is where the real danger is — after all, that's where someone could brute-force the code. But the generate-OTP endpoint turned out to be the easier target, because exploiting it doesn't require guessing anything. Just call it, repeatedly.
A few consequences became clear once I thought it through:
- Cost attack. If OTPs go out over SMS, every request has a price. Spamming thousands of requests against one number can run up a real bill on the SMS gateway — without the attacker ever needing account access.
- Denial of service against the victim. The person whose email or number gets targeted ends up flooded with OTP notifications, which is disruptive on its own and can even mask other suspicious activity happening on their account at the same time.
- Resource exhaustion. If generating an OTP is a heavier operation — a DB query, a synchronous email send — spamming it can slow down the rest of the server too.
This is the part that's easy to overlook when securing an OTP system: people naturally lock down the verify step and forget that the request step needs its own protection just as much.
The Redesign: Splitting Responsibility Between Redis and the Database
The turning point was drawing a clear line between two storage layers: Redis holds everything short-lived and speed-sensitive, while the database gets simplified down to a historical log.
Redis stores the OTP session with a TTL for the session as a whole — no cron job or extra query needed to clean up stale data, Redis just deletes it once time is up. But since the OTP code itself has a shorter lifespan than the session (5 minutes versus a 30-minute session), checking whether the code is still valid stays manual in the application logic, rather than being fully offloaded to Redis:
otp_code = f"{secrets.randbelow(1000000):06d}"
r.setex(f"otp:{token}", 1800, json.dumps({
"email": email,
"otp": otp_code,
"otp_created_at": time.time(),
}))
Enter fullscreen mode Exit fullscreen mode
if time.time() - data["otp_created_at"] > 300:
return res_error("OTP code has expired, please request a new one.", status.HTTP_400_BAD_REQUEST)
Enter fullscreen mode Exit fullscreen mode
Redis handles expiry at the session level — when the whole token should die — while the OTP code's own expiry stays as application logic, so a user can still resend within the longer session window without an old, expired code remaining valid.
The database side, meanwhile, got drastically simplified. The expired_at and is_used fields are gone, and the plaintext otp field became a hash — because its only job now is recording history, not validating anything:
class OTPVerifications(models.Model):
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
otp_hash = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
used_at = models.DateTimeField(null=True, blank=True)
Enter fullscreen mode Exit fullscreen mode
The change looks small on paper, but the effect is significant: validation got faster (no database query needed at the verification step), and the database stopped being a point of failure for expiry bugs — that responsibility now lives entirely in Redis and the application logic around it.
From Two Keys to One: Simplifying the Token/Session Design
Once Redis was set as the storage layer for OTPs, the next question came up: how do I let a user request a new code (resend) without forcing them to register from scratch, while still keeping a clear deadline for when the session actually has to die?
My first attempt looked reasonable on paper: split the OTP code and the session into two separate Redis keys — one for the active code (otp:{token}, short TTL), another for the overall session (otp_session:{token}, longer TTL).
r.setex(f"otp:{token}", 300, json.dumps({"email": email, "otp": otp_code}))
r.setex(f"otp_session:{token}", 1800, email)
Enter fullscreen mode Exit fullscreen mode
But thinking it through further, this actually introduced new problems: the email ended up duplicated across two places, two TTLs had to stay in sync, and if one key disappeared before the other — say, from a bug or a timing delay — the state became ambiguous: the session alive but the OTP gone, or the other way around.
The solution turned out to be much simpler than I expected: one key, with a TTL long enough for the session, and the OTP code's own expiry checked manually in the application logic (as covered in the previous section). When a user resends, the value gets updated but the session TTL doesn't reset — using the KEEPTTL option Redis provides for exactly this kind of case:
data.update({"otp": new_otp_code, "otp_created_at": time.time()})
r.set(f"otp:{token}", json.dumps(data), keepttl=True)
Enter fullscreen mode Exit fullscreen mode
With this, the OTP code changes every time it's resent, but the session clock keeps running from the original starting point — a user who resends at minute 25 still runs out of session at minute 30, not minute 55. One key, one source of truth, no risk of two pieces of data drifting out of sync.
The Bug That Slipped Through: A Daily Limit That Didn't Actually Limit Anything
With the token and session solid, I moved on to something that clearly needed protecting: limiting how many times an OTP could be requested in a day. The logic looked sound — a per-email counter that resets every 24 hours:
daily_key = f"otp_daily:{email}"
count = r.incr(daily_key)
if count == 1:
r.expire(daily_key, 86400)
if count > 10:
return res_error("Daily OTP request limit reached.", status.HTTP_429_TOO_MANY_REQUESTS)
Enter fullscreen mode Exit fullscreen mode
This sat inside the resend endpoint, right after the token validation. It looked complete — until I tried walking through the attack scenario myself from the start.
The problem was in the order of checks. Before that daily-limit code could ever run, the request first validated whether the token was still alive in Redis:
raw = r.get(f"otp:{token}")
if not raw:
return res_error("Session expired, please register again.", status.HTTP_400_BAD_REQUEST)
Enter fullscreen mode Exit fullscreen mode
Once the token expired (30 minutes in), the request stopped right there — the daily limit below it never got a chance to run. A user, or an attacker, who found their session dead could just register again to get a fresh token. And since the register endpoint never checked otp_daily:{email} at all, whatever count had built up on the resend side meant nothing — every re-registration was effectively a brand-new allowance.
I had considered this feature done, until I sat down and ran the numbers myself: an attacker could just re-register every 30 minutes, and the daily limit I thought was protecting the system never actually stopped anything.
The Final Decision: Basing Limits on Real Risk, Not Assumptions
The obvious fix was to move the daily-limit check so it runs at every point an OTP gets generated — both register and resend — instead of just one endpoint. But before adding another layer of protection, I stopped and asked a more basic question: how big is the risk I'm actually trying to close?
I worked through the most aggressive attack scenario possible under the existing design: a 5-per-3-hours register limit, a 60-second resend cooldown, and a 5-failed-attempt lockout on verification.
The key detail that made this manageable: the attempt counter is keyed by token, not by OTP code. So even if an attacker resends to get a fresh code, the failed-attempt count doesn't reset — it stays tied to the session for as long as that token lives:
attempt_key = f"otp_attempt:{token}"
attempts = int(r.get(attempt_key) or 0)
if attempts >= 5:
r.delete(f"otp:{token}")
return res_error("Too many failed attempts. Please request a new OTP.", status.HTTP_429_TOO_MANY_REQUESTS)
Enter fullscreen mode Exit fullscreen mode
That means resending doesn't buy an attacker a fresh set of guesses — it only gets them a new code to guess, still under the same shrinking budget of attempts:
5 registrations (limit) × 5 verify attempts per session = 25 total verify attempts
Enter fullscreen mode Exit fullscreen mode
25 attempts against a 6-digit code — 1 million possible combinations — puts the odds of a successful guess at roughly 0.0025%. That's low enough that the 5-attempt lockout, combined with the register limit, is enough on its own. No extra protection needed there.
As for spam volume: with a 60-second cooldown and a 30-minute session, a single session allows at most ~30 resends. Across 5 registrations, that's up to 150 emails sent within a 3-hour window. If OTPs go out over email, that's a low, acceptable cost. If they go out over SMS, the math changes — the cooldown would need to stretch to a few minutes instead of 60 seconds to keep the cost down.
The conclusion: the separate daily limit on resend didn't need patching — it needed to be removed. The protections already in place elsewhere — the register limit, the resend cooldown, the verify lockout — were enough to cover the real risk, without adding another layer that turned out, as I'd just found, to introduce a new gap of its own.
Testing: Why the Backend and Frontend Needed Different Levels of Rigor
Once the backend felt solid, I asked myself: does the frontend need to be tested this thoroughly too? My OTP verification page in React really only does two things — read the token from the query param, and submit the form to the backend. There are no granular unit tests per function, a stark contrast to the backend, which I tested down to lockout scenarios, cooldowns, and other.
It turns out this isn't a gap — it reflects where the real risk actually sits in each layer. The backend is the source of truth: expiry, brute-force protection, atomic counters, all logic that, if wrong, can quietly become a security hole or corrupted data with nobody noticing until it's exploited. The frontend, on the other hand, is mostly presentational — take input, send it to the backend, display the result. If something breaks there, it's usually visible right away (a broken form, a visual bug), and easy to fix once spotted.
This also lines up with what I've generally read about testing practices across many projects: the backend gets detailed unit tests for business logic and validation, while the frontend leans more toward high-level integration tests — "user fills the form, clicks submit, gets redirected to the right page" — rather than unit tests for every small function. This isn't about the frontend mattering less; it's about putting testing effort where it actually needs the guarantee.
What I Took Away From This
Looking back, this all started from a bug that seemed minor — an email disappearing on page refresh. If I had just patched that on the frontend (say, storing the email in sessionStorage) and stopped there, my OTP system would have kept running fine — but every gap I found along the way would still be sitting there, unnoticed until someone eventually exploited it.
What I learned wasn't just about Redis, tokens, or rate limiting as techniques. What mattered more was the habit of pausing to ask "why is this happening" instead of jumping straight to the fastest fix. That small refresh bug forced me to walk back through every assumption I'd taken for granted — and some of them, like the daily limit that turned out to be easy to bypass, would never have surfaced if I hadn't ended up redesigning things from scratch.
What I learned: security isn't about adding as much protection as possible. It's more about calculating the real risk — who might attack, how costly it actually is, and how likely that scenario really is. Sometimes that means adding a new layer. But sometimes, like the daily limit I ended up removing, it means realizing that protection which looked safe was really just adding complexity without any real benefit.
The full code for this system is available in the project repos:
- Backend (Django + DRF + Redis): github.com/Iqbal120708/url-shortener
- Frontend (React): github.com/Iqbal120708/url-shortener-web
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.