[PR #8584] fix(phone-number): deduplicate verification records on repeated OTP requests #24980

Open
opened 2026-04-15 22:40:11 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/8584
Author: @thefotios
Created: 3/12/2026
Status: 🔄 Open

Base: mainHead: 03-12-fix_phone-number_deduplicate_verification_records_on_repeated_otp_requests


📝 Commits (1)

  • 93f8dc9 fix(phone-number): deduplicate verification records on repeated OTP requests

📊 Changes

2 files changed (+120 additions, -3 deletions)

View changed files

📝 packages/better-auth/src/plugins/phone-number/phone-number.test.ts (+102 -0)
📝 packages/better-auth/src/plugins/phone-number/routes.ts (+18 -3)

📄 Description

Summary

  • Root cause: createVerificationValue inserts without dedup — when a user requests a second OTP before the first expires, duplicate verification records accumulate. Subsequent calls to updateVerificationByIdentifier or deleteVerificationByIdentifier then operate on multiple rows for the same identifier, causing 500 errors.
  • Fix: Add a createOrReplaceVerification helper that deletes any existing verification for the identifier before creating a new one. Applied to all 3 createVerificationValue call sites in the phone-number plugin:
    • signInPhoneNumber (requireVerification flow)
    • sendPhoneNumberOTP
    • requestPasswordResetPhoneNumber

Trade-offs

This fix adds an extra DELETE query per OTP send (n+1). In practice, OTP sends are low-frequency user-initiated actions, and the DELETE is a simple indexed lookup on identifier — so the DB load is negligible. The cost is primarily the extra network round-trip to the database.

Single-statement alternatives (follow-up)

Since this helper centralizes the logic in one place, a follow-up could detect DB capabilities and use the optimal strategy per database:

  1. UpsertINSERT ... ON CONFLICT (identifier) DO UPDATE (Postgres/SQLite) or ON DUPLICATE KEY UPDATE (MySQL). Single statement, atomic, no extra round-trip. Requires a unique constraint on verification.identifier.
  2. REPLACE INTO (SQLite/MySQL) — atomic delete-then-insert in one statement. Also requires a unique constraint.
  3. Transaction — wrap DELETE + INSERT in the adapter's transaction() method. Atomic, no unique constraint needed, still two statements but could be pipelined. The DBAdapter already exposes transaction support.

Options 1 and 2 are the cleanest but depend on adding unique: true to the identifier column in get-tables.ts — a breaking change for existing DBs that may already have duplicate records blocking the migration. Option 3 could work today by passing ctx.context.adapter into the helper for its transaction support.

If the n+1 is unacceptable for this PR, happy to pursue any of these here. Otherwise they make good follow-ups since the helper already centralizes the logic, making the swap straightforward.

Test plan

  • Added test: repeated sendOtp calls produce exactly 1 verification record (not 3)
  • Added test: wrong code → re-send OTP → correct latest code succeeds
  • Added test: repeated requestPasswordReset calls produce exactly 1 verification record (not 2)
  • Added test: repeated password reset requests followed by reset succeeds
  • All 30 tests pass (4 new + 26 existing, zero regressions)
  • No type errors introduced
  • Lint clean

Future improvements

  • Adapter-aware verification replacementDBAdapter exposes id, options.adapterConfig.adapterId, and options.type, which could be used to select the optimal dedup strategy per database (e.g., INSERT ... ON CONFLICT DO UPDATE for Postgres, REPLACE INTO for MySQL/SQLite, or transaction() as a universal fallback). This could be implemented as a createOrReplaceVerificationValue method on InternalAdapter that inspects the adapter config and dispatches accordingly.
  • Add unique: true to identifier in get-tables.ts (breaking change for existing DBs with duplicates — should be bundled with a minor version bump). Users who want to add it early can do so safely after this fix lands, and npx auth migrate won't overwrite it.
  • Fix the same latent bug in email-otp and other plugins

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/8584 **Author:** [@thefotios](https://github.com/thefotios) **Created:** 3/12/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `03-12-fix_phone-number_deduplicate_verification_records_on_repeated_otp_requests` --- ### 📝 Commits (1) - [`93f8dc9`](https://github.com/better-auth/better-auth/commit/93f8dc965aae896639bef55af796793128e2b785) fix(phone-number): deduplicate verification records on repeated OTP requests ### 📊 Changes **2 files changed** (+120 additions, -3 deletions) <details> <summary>View changed files</summary> 📝 `packages/better-auth/src/plugins/phone-number/phone-number.test.ts` (+102 -0) 📝 `packages/better-auth/src/plugins/phone-number/routes.ts` (+18 -3) </details> ### 📄 Description ## Summary - **Root cause:** `createVerificationValue` inserts without dedup — when a user requests a second OTP before the first expires, duplicate verification records accumulate. Subsequent calls to `updateVerificationByIdentifier` or `deleteVerificationByIdentifier` then operate on multiple rows for the same identifier, causing 500 errors. - **Fix:** Add a `createOrReplaceVerification` helper that deletes any existing verification for the identifier before creating a new one. Applied to all 3 `createVerificationValue` call sites in the phone-number plugin: - `signInPhoneNumber` (requireVerification flow) - `sendPhoneNumberOTP` - `requestPasswordResetPhoneNumber` ## Trade-offs This fix adds an extra DELETE query per OTP send (n+1). In practice, OTP sends are low-frequency user-initiated actions, and the DELETE is a simple indexed lookup on `identifier` — so the DB load is negligible. The cost is primarily the extra network round-trip to the database. ### Single-statement alternatives (follow-up) Since this helper centralizes the logic in one place, a follow-up could detect DB capabilities and use the optimal strategy per database: 1. **Upsert** — `INSERT ... ON CONFLICT (identifier) DO UPDATE` (Postgres/SQLite) or `ON DUPLICATE KEY UPDATE` (MySQL). Single statement, atomic, no extra round-trip. Requires a unique constraint on `verification.identifier`. 2. **`REPLACE INTO`** (SQLite/MySQL) — atomic delete-then-insert in one statement. Also requires a unique constraint. 3. **Transaction** — wrap DELETE + INSERT in the adapter's `transaction()` method. Atomic, no unique constraint needed, still two statements but could be pipelined. The `DBAdapter` already exposes `transaction` support. Options 1 and 2 are the cleanest but depend on adding `unique: true` to the `identifier` column in `get-tables.ts` — a breaking change for existing DBs that may already have duplicate records blocking the migration. Option 3 could work today by passing `ctx.context.adapter` into the helper for its transaction support. If the n+1 is unacceptable for this PR, happy to pursue any of these here. Otherwise they make good follow-ups since the helper already centralizes the logic, making the swap straightforward. ## Test plan - [x] Added test: repeated `sendOtp` calls produce exactly 1 verification record (not 3) - [x] Added test: wrong code → re-send OTP → correct latest code succeeds - [x] Added test: repeated `requestPasswordReset` calls produce exactly 1 verification record (not 2) - [x] Added test: repeated password reset requests followed by reset succeeds - [x] All 30 tests pass (4 new + 26 existing, zero regressions) - [x] No type errors introduced - [x] Lint clean ## Future improvements - **Adapter-aware verification replacement** — `DBAdapter` exposes `id`, `options.adapterConfig.adapterId`, and `options.type`, which could be used to select the optimal dedup strategy per database (e.g., `INSERT ... ON CONFLICT DO UPDATE` for Postgres, `REPLACE INTO` for MySQL/SQLite, or `transaction()` as a universal fallback). This could be implemented as a `createOrReplaceVerificationValue` method on `InternalAdapter` that inspects the adapter config and dispatches accordingly. - Add `unique: true` to `identifier` in `get-tables.ts` (breaking change for existing DBs with duplicates — should be bundled with a minor version bump). Users who want to add it early can do so safely after this fix lands, and `npx auth migrate` won't overwrite it. - Fix the same latent bug in email-otp and other plugins --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-15 22:40:11 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#24980