Skip to content

fix(auth): invalidate email OTP on a failed guess, not just a match - #2224

Merged
richiemcilroy merged 7 commits into
CapSoftware:mainfrom
addyCooks:fix/otp-verification-token-brute-force
Oct 22, 2026
Merged

richiemcilroy merged 7 commits into
CapSoftware:mainfrom
addyCooks:fix/otp-verification-token-brute-force

Conversation

@addyCooks

@addyCooks addyCooks commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #2221. useVerificationToken in the NextAuth Drizzle adapter only invalidated the verification row on a successful code match. A wrong 6-digit guess found no matching row and simply returned null, leaving the real code untouched and guessable for the rest of its 10-minute TTL a brute-forceable account takeover.

Root cause

useVerificationToken queried verification_tokens by the guessed token value. Since the row is keyed by identifier, a wrong guess never matched, so there was nothing to invalidate on failure. The mobile login path (apps/web/app/api/mobile/[...route]/route.ts) already avoided this by looking the row up by identifier first and deleting it on any outcome — the web path had no equivalent.

Fix

In packages/database/auth/drizzle-adapter.ts:

  • Look up the verification row by identifier (the key an attacker doesn't control) instead of by the guessed token.
  • Delete the row on every attempt match or mismatch before returning, so a wrong guess burns the code immediately instead of leaving it valid for reuse.
  • Return the row only when the stored token actually matches the guess.

This mirrors the correct mobile behavior and closes the gap without depending on rate limiting (AUTH_OTP_VERIFY/AUTH_OTP_SEND remain unwired and, even wired, are Vercel-Firewall-only and fail open on self-hosted deployments so this fix is the durable, hosting-agnostic one, as noted in the issue).

Testing

  • Added apps/web/__tests__/unit/verification-token.test.ts (no prior test coverage existed for this adapter method), covering:
    • A wrong guess deletes the stored code; a subsequent correct guess against the same code also fails (proves it's burned, not just rejected).
    • A correct guess consumes and returns the row.
    • No code requested for an identifier returns null.
  • Ran the full apps/web unit suite (pnpm test): 2542 passed. 3 pre-existing failures (Slack manifest brand-color drift, an agent-api-handler hook timeout, an sso-login-pages render timeout) are unrelated verified none of those files reference the changed code.
  • tsc --noEmit on packages/database is clean.

Related

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with one non-blocking improvement to the mixed-guess test.

Findings

  1. P2 Test loses the winning guess ▶
Fix with agent prompt
### Issue 1
apps/web/__tests__/integration/sso-database.test.ts:393-397
The mixed-guess test does not check which guess succeeded. `useVerificationToken` returns the stored row, so checking its `token` against the stored token would also pass if a wrong guess incorrectly returned that row. This leaves a gap in the test, not a defect in the current sign-in code. Keep each result paired with its guess and assert that every wrong guess returns `null`.

```suggestion
			const successes = attempts.filter((result) => result !== null);
			expect(successes.length).toBeLessThanOrEqual(1);
			for (const [index, result] of attempts.entries()) {
				if (guesses[index] !== token) {
					expect(result).toBeNull();
				} else if (result !== null) {
					expect(result).toMatchObject({ identifier, token });
				}
			}
```

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Email verification now looks up the code by email address and consumes it before checking the guess.

  • Web email sign-in uses up the saved code after a wrong guess.

Reviews (2) · Last reviewed commit: "test(auth): bind the adapter's useVerifi..." · Reviewed by Greptile

useVerificationToken looked up the row by the guessed token, so a wrong
6-digit guess simply found no row and left the real code untouched,
guessable for the rest of its 10-minute TTL. Look the row up by
identifier instead (mirrors the mobile login path) and delete it on
every attempt, so a wrong guess burns the code immediately.
Comment on lines +526 to +528
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.token, token),
eq(verificationTokens.identifier, row.identifier),
),
);
return { ...row, identifier: storedIdentifier };
.where(eq(verificationTokens.identifier, row.identifier));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Concurrent attempts reuse tokens

Concurrent verification requests can select the same token before either deletion finishes. Because the deletion result is ignored, each request can then authenticate using its previously selected row. A correct request may succeed even after a racing wrong guess was supposed to burn the token, and concurrent correct requests may both succeed. A resend racing this sequence can also have its newly issued token deleted while the old token authenticates. The lookup, invalidation, and match decision must be atomic, with concurrency covered by the existing real-MySQL integration-test facility.

How this was verified: The callback accepts concurrent requests, while the adapter performs an unlocked SELECT followed by a separate DELETE and returns the previously selected row without checking the deletion result.

Knowledge Base Used: Data and identity platform

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/auth/drizzle-adapter.ts
Line: 526-528

Comment:
**Concurrent attempts reuse tokens**

Concurrent verification requests can select the same token before either deletion finishes. Because the deletion result is ignored, each request can then authenticate using its previously selected row. A correct request may succeed even after a racing wrong guess was supposed to burn the token, and concurrent correct requests may both succeed. A resend racing this sequence can also have its newly issued token deleted while the old token authenticates. The lookup, invalidation, and match decision must be atomic, with concurrency covered by the existing real-MySQL integration-test facility.

**How this was verified:** The callback accepts concurrent requests, while the adapter performs an unlocked SELECT followed by a separate DELETE and returns the previously selected row without checking the deletion result.

**Knowledge Base Used:** [Data and identity platform](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/data-and-identity-platform.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superagent found 1 security concern(s).

}
// Delete on every attempt (not just a match) so a wrong guess burns the
// code instead of leaving it guessable for the rest of its TTL.
await db

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: OTP consumption is vulnerable to concurrent stale-row authentication

Separate SELECT/DELETE/compare steps can let concurrent requests authenticate from the same stale OTP row.

Atomically consume the identifier's token and authorize only when the conditional operation claims the row; add MySQL race tests.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="packages/database/auth/drizzle-adapter.ts">
<violation number="1" location="packages/database/auth/drizzle-adapter.ts:526">
<priority>P1</priority>
<title>OTP consumption is vulnerable to concurrent stale-row authentication</title>
<evidence>The changed method selects a token by identifier, then performs a separate DELETE and finally compares the previously selected row's token. The deletion result is ignored. Concurrent verification requests can therefore both read the same row before either delete completes and return success based on stale state, allowing multiple uses of a one-time code or allowing a correct attempt to succeed after a racing wrong attempt. A resend racing this sequence can also be affected because deletion is keyed only by identifier.</evidence>
<recommendation>Make lookup, single-use invalidation, and match decision atomic at the database level: use a transaction with appropriate row locking or an atomic conditional DELETE/consume operation whose affected-row result determines whether the request may authenticate. Ensure a replacement token cannot be deleted by an in-flight consume operation, and add a real-MySQL concurrency test covering simultaneous correct and incorrect attempts and resend races.</recommendation>
</violation>
</file>

useVerificationToken read the row, then deleted it, then compared the
token from the stale in-memory copy without checking whether the
delete actually removed anything. Concurrent requests could all read
the same row before any of them deleted it, letting more than one
succeed, and a wrong guess's unconditional delete-by-identifier could
also wipe out a code from a resend that landed in between.

Scope the delete to the exact (identifier, token) pair just read and
only treat the guess as claimed when that delete affects exactly one
row, so at most one concurrent request can consume a given code and a
racing resend's row is never clobbered.
Adds a case to the existing isolated-MySQL integration facility
(gated on CAP_SSO_TEST_DATABASE_URL, same as the SSO tests in this
file) firing 8 concurrent useVerificationToken calls for one code and
asserting exactly one claims it. Requested in review as the
concurrency coverage for the atomic-consumption fix, since the prior
unit test only exercised it against an in-memory fake.
…MySQL

Extends the isolated-MySQL facility with a case firing one correct
guess alongside several wrong ones concurrently, asserting at most
one attempt ever authenticates and, when one does, it's the correct
guess - never a wrong one that raced ahead of it. Addresses the
"simultaneous correct and incorrect attempts" scenario named in a
follow-up security scan of the atomic-consumption fix.
@richiemcilroy

Copy link
Copy Markdown
Member

Thanks, this is the cleanest fix for #2221 and we're consolidating the duplicates here. Your later commits address the concurrency findings, but Greptile last scored an older commit (3/5). Please re-trigger Greptile on the current head and resolve the outdated threads. One thing to consider: burning the code on any wrong guess lets someone who knows a user's email invalidate their live code, so a small per-token attempt limit may be a better trade-off. We need Greptile 5/5 and green CI before we review.

@richiemcilroy

Copy link
Copy Markdown
Member

Thanks @addyCooks! Greptile is at 3/5 here. We only merge once Greptile reaches 5/5, so please address its findings in the summary above (or reply to them if you think one is wrong), then comment @greptileai review to re-run it. If the branch is behind main, please merge or rebase on main first. Once it is 5/5 we will run the full verification suite on it.

@richiemcilroy

Copy link
Copy Markdown
Member

@greptileai please review the latest commits (atomic OTP consumption + main merged in).

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Skipped Skipped Oct 22, 2026 5:32am UTC

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 20, 2026 19:02 Inactive
@vercel
vercel Bot temporarily deployed to Preview – cap-status October 20, 2026 19:30 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member

@greptileai review

Comment on lines +393 to +397
const successes = attempts.filter((result) => result !== null);
expect(successes.length).toBeLessThanOrEqual(1);
for (const success of successes) {
expect(success).toMatchObject({ identifier, token });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test loses the winning guess

The mixed-guess test does not check which guess succeeded. useVerificationToken returns the stored row, so checking its token against the stored token would also pass if a wrong guess incorrectly returned that row. This leaves a gap in the test, not a defect in the current sign-in code. Keep each result paired with its guess and assert that every wrong guess returns null.

Suggested change
const successes = attempts.filter((result) => result !== null);
expect(successes.length).toBeLessThanOrEqual(1);
for (const success of successes) {
expect(success).toMatchObject({ identifier, token });
}
const successes = attempts.filter((result) => result !== null);
expect(successes.length).toBeLessThanOrEqual(1);
for (const [index, result] of attempts.entries()) {
if (guesses[index] !== token) {
expect(result).toBeNull();
} else if (result !== null) {
expect(result).toMatchObject({ identifier, token });
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/integration/sso-database.test.ts
Line: 393-397

Comment:
**Test loses the winning guess**

The mixed-guess test does not check which guess succeeded. `useVerificationToken` returns the stored row, so checking its `token` against the stored token would also pass if a wrong guess incorrectly returned that row. This leaves a gap in the test, not a defect in the current sign-in code. Keep each result paired with its guess and assert that every wrong guess returns `null`.

```suggestion
			const successes = attempts.filter((result) => result !== null);
			expect(successes.length).toBeLessThanOrEqual(1);
			for (const [index, result] of attempts.entries()) {
				if (guesses[index] !== token) {
					expect(result).toBeNull();
				} else if (result !== null) {
					expect(result).toMatchObject({ identifier, token });
				}
			}
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 22, 2026 05:32 Inactive
@richiemcilroy
richiemcilroy merged commit 1bd6cbb into CapSoftware:main Oct 22, 2026
30 of 31 checks passed

This branch was previously deployed

1 inactive deployment
Preview – cap-status — 4f5d0a17 Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants