Authentication & OTP email OTP code expiry

Choosing an OTP Code Expiry That Survives Email Delays

Set OTP expiry too short and email latency locks out real users; too long and you widen the attack window. How to choose a code TTL that balances security and delivery reality.

A verification code with a countdown timer, weighed against an email delivery delay bar
A verification code with a countdown timer, weighed against an email delivery delay bar

There's a tempting piece of security folklore: "make the code expire fast, it's safer." For a TOTP authenticator app, where the code regenerates every 30 seconds on the user's own device with no network in the path, that instinct is right. For a code delivered by email, it's a trap. Email introduces delivery latency that a device-based code never has, and if your expiry is shorter than that latency, you're not tightening security — you're locking out legitimate users and teaching them to hammer "resend."

This is a design decision, and like most design decisions it's a trade-off with two failure modes. Set it right by understanding both.

An expiry shorter than your worst-case time-to-inbox doesn't harden your app. It just converts delivery delay into failed logins.

Two ways to get it wrong#

Too short. The user requests a code. It takes four minutes to arrive because of greylisting on a fresh recipient domain (a delay we break down in why OTP emails arrive late). By the time they open it, your 60-second window has closed. They request another. It's also slow. Now they've triggered your rate limiter, and they can't log in at all. You've turned a delivery problem into a lockout.

Too long. A code that stays valid for 24 hours sits in the user's inbox as a live credential. If that inbox is later compromised, or the code was phished, the attacker has a wide, comfortable window. Long-lived codes also interact badly with users who request several — you now have multiple valid codes floating around unless you invalidate old ones.

The right window is the narrowest one that still clears your realistic delivery-plus-human time. Everything tighter is self-harm; everything looser is unnecessary exposure.

What the standards actually say#

The relevant guidance is consistent on the principles, even where it leaves the exact number to you:

  • OWASP's Forgot Password guidance is explicit that codes and tokens should be single-use and expire after an appropriate period — and that you should not change the account state until a valid code is presented.
  • RFC 6238 (TOTP) is built around short time-steps precisely because the code lives on the user's device with no transmission delay — which is exactly the condition email OTP does not meet. Borrowing TOTP's 30-second instinct for email is the core mistake.
  • OWASP also stresses rate limiting per account and lockouts to stop brute force, because that — not a short lifetime — is what actually caps the number of guesses an attacker gets.

The takeaway: security in email OTP comes mostly from single use + guess limits, and only secondarily from a short lifetime. That reframes the whole decision.

A decision framework for the window#

Pick your expiry from your own delivery data, not a number you saw in a tutorial.

InputHow to get itTypical value
Worst-case time-to-inbox (p95/p99)Measure from submission to email.accepted across fresh domainsseconds to a few minutes
Human handling timeUser switches apps, finds email, reads/types code30–90 seconds
Safety marginFor retries and slow inboxes+a few minutes
Suggested email OTP windowSum of the above, rounded up10–15 minutes

Then constrain the risk that the longer window creates:

  • Single-use. The first correct submission burns the code. A second attempt with the same code fails.
  • Invalidate on resend. When a user requests a new code, kill the previous one. One live code at a time.
  • Per-account rate limits. Cap requests (e.g. a small number per 10–15 minutes) and cap verification attempts before a temporary lockout. This is what stops brute force — a six-digit code with three attempts is far safer than a six-digit code that merely expires quickly.
  • Bind to context. Tie the code to the session/device that requested it where your flow allows, so a code phished to another device is less useful.
A balance diagram: left side shows user lockouts from short expiry and delivery latency, right side shows attack window from long expiry, with single-use and rate limiting as the true security controls
The lifetime sets the balance point; single-use and rate limiting do the real security work.

Hands-on: encode the expiry where the user sees it#

Two rules for the message itself. State the expiry in the email so the user knows their window, and keep the code prominent. Use a sandbox key (nv_test_…) while building.

Bash
curl https://api.notifiva.com/v1/send \
  -H "Authorization: Bearer nv_test_your_key_here" \
  -H "Idempotency-Key: otp-req-2f81a0-user-4821" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "[email protected]",
    "to": "[email protected]",
    "subject": "Your code is 620914",
    "text": "Your verification code is 620914. It expires in 15 minutes and can be used once. If you did not request it, ignore this email.",
    "tag": "otp"
  }'

The server side is where the policy lives. Store the code hashed, with an explicit expiry and an attempt counter:

Python
# Python — issuing and verifying an email OTP with a sane window
import secrets, hashlib, time

OTP_TTL_SECONDS = 15 * 60      # matches the "expires in 15 minutes" copy
MAX_ATTEMPTS = 3

def issue_otp(store, user_id):
    code = f"{secrets.randbelow(1_000_000):06d}"   # cryptographically random
    store.set(user_id, {
        "hash": hashlib.sha256(code.encode()).hexdigest(),
        "expires_at": time.time() + OTP_TTL_SECONDS,
        "attempts": 0,
    })
    return code  # hand to your send call; never log it

def verify_otp(store, user_id, submitted):
    rec = store.get(user_id)
    if not rec or time.time() > rec["expires_at"]:
        return False                       # expired or none issued
    if rec["attempts"] >= MAX_ATTEMPTS:
        return False                       # locked; force a new request
    rec["attempts"] += 1
    ok = secrets.compare_digest(
        rec["hash"], hashlib.sha256(submitted.encode()).hexdigest()
    )
    if ok:
        store.delete(user_id)              # single use — burn on success
    else:
        store.set(user_id, rec)
    return ok

The OTP_TTL_SECONDS constant and the "expires in 15 minutes" copy must always agree. Drift between them is a support-ticket generator.

When a shorter window is actually fine#

Short expiries aren't wrong everywhere. If you deliver the code by a channel with near-zero latency — an in-app push, an authenticator, or SMS in a region where it's reliably instant — a two- or three-minute window is reasonable, because time-to-inbox is no longer the constraint. The rule generalises: your expiry floor is your delivery latency ceiling. Email just has a higher ceiling than the alternatives, so it needs a longer floor.

Editorial disclosure: Drafted with AI assistance; reviewed, fact-checked and edited by Alkım Kaplaner. Next scheduled review: .

Frequently asked questions

What's a good expiry for an email OTP?

For most consumer email OTP, 10–15 minutes. That clears realistic time-to-inbox (which can be minutes on fresh domains) plus the time a human takes to switch apps and type. Pair it with single-use and per-account rate limits so the longer window doesn't cost you security.

Isn't a longer expiry less secure?

Only marginally, and only if you rely on lifetime alone. A single-use code with a small number of allowed attempts before lockout is what actually limits an attacker. The expiry mainly protects against a code sitting valid in a later-compromised inbox — which is real, but bounded by single-use and resend-invalidation.

Should requesting a new code cancel the old one?

Yes. Invalidate the previous code whenever you issue a new one, so only one code is ever valid per account at a time. Multiple live codes widen the attack surface and confuse users.

How many verification attempts should I allow?

A small number — often three to five — before a temporary lockout that forces a fresh request. This caps brute-force guessing far more effectively than a short lifetime does, especially for six-digit numeric codes.

Can I use TOTP expiry rules for email codes?

No. TOTP's short windows work because the code lives on the user's device with no delivery delay. Email adds latency, so copying the 30–60 second instinct locks out real users. Size the window to email's delivery reality instead.

Send transactional email you can rely on

Notifiva gives you a REST API and an SMTP relay, SPF/DKIM signing, delivery webhooks and per-message logs — so the mail your users are waiting on actually arrives.