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
POST /api/auth/signin/email for a target address.
- Fire guesses at
GET /api/auth/callback/email?email=<target>&token=<6-digit>.
- 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
Summary
useVerificationTokeninpackages/database/auth/drizzle-adapter.ts(lines 512-538) onlydeletes the verification row on a successful match. A wrong 6-digit code guess finds no row
(lookup is by exact
tokenvalue), logs a warning, and returnsnullthe real code is leftuntouched. 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-548deletes the row on a tokenmismatch before returning
"invalid", burning the code on the first wrong attempt. The web(NextAuth) path has no equivalent.
Repro
POST /api/auth/signin/emailfor a target address.GET /api/auth/callback/email?email=<target>&token=<6-digit>.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) aredeclared but have zero call sites (see #2039, PR #1924 open and stale since June, PR #2040
closed without wiring these two). Even once wired,
isRateLimitedis backed by Vercel Firewalland 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
useVerificationTokenitself.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_SENDasdefense-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