#2221·Cap

Email OTP login has no attempt limit: useVerificationToken doesn't invalidate the code on a wrong guess (brute-forceable account takeover)

Author: addyCooksCreated Sep 5, 2026Updated Sep 12, 2026
Labelsbug

Summary

useVerificationToken in packages/database/auth/drizzle-adapter.ts (lines 512-538) only deletes the verification row on a successful match. A wrong 6-digit code guess finds no row (lookup is by exact token value), logs a warning, and returns null the real code is left untouched. It can be guessed without limit for its entire TTL (10 minutes as of #2068).

This is a logic bug, not just a missing rate limit: the mobile login path already does this correctly. apps/web/app/api/mobile/[...route]/route.ts:543-548 deletes the row on a token mismatch before returning "invalid", burning the code on the first wrong attempt. The web (NextAuth) path has no equivalent.

Repro

  1. POST /api/auth/signin/email for a target address.
  2. Fire guesses at GET /api/auth/callback/email?email=<target>&token=<6-digit>.
  3. Codes come from crypto.randomInt(100000, 1000000) (900,000 possible values, auth-options.ts:138). At ~100 req/s, ~6.7% success chance within the 10-minute window; sustained guessing lands a hit in a few hours.

Why this isn't closed by existing rate-limit ids

RATE_LIMIT_IDS.AUTH_OTP_VERIFY / AUTH_OTP_SEND (apps/web/lib/rate-limit.ts:83-85) are declared but have zero call sites (see #2039, PR #1924 open and stale since June, PR #2040 closed without wiring these two). Even once wired, isRateLimited is backed by Vercel Firewall and fails open without a matching dashboard rule, so it provides no protection on self-hosted deployments regardless. The durable, hosting-agnostic fix has to live in useVerificationToken itself.

Suggested fix

Delete (or otherwise invalidate) the verification row on a failed match too, not only on success mirroring the mobile implementation. Wiring AUTH_OTP_VERIFY/AUTH_OTP_SEND as defense-in-depth on Vercel deployments is worth doing separately, but shouldn't be treated as the fix on its own since it doesn't cover self-hosted instances.

Related

  • #2039 (open), broader unwired-rate-limit-ids issue, includes these two ids but not this root cause
  • #1924 (open, stale), Firewall-based rate limiting, never merged
  • #2068 (merged), fixed the 24h→10min window, explicitly left the attempt-counter fix as follow-up
  • #2040 (closed), deliberately punted on wiring these two ids