[GH-ISSUE #8376] Passkey verify-authentication: findVerificationValue cleanup races with deleteVerificationValue #28396

Open
opened 2026-04-17 19:51:17 -05:00 by GiteaMirror · 0 comments
Owner

Originally created by @skgbafa on GitHub (Mar 4, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8376

Is this suited for github?

  • Yes, this is suited for github

Bug Description

During POST /api/auth/passkey/verify-authentication, findVerificationValue() runs an automatic cleanup of expired verification records before returning the found record. If the found record is at or past its expiration boundary, the cleanup deletes it. The verification flow then continues (the challenge data is already in memory), but when deleteVerificationValue(data.id) is called at the end, the record no longer exists, causing a Prisma P2025 error.

The Prisma adapter does catch P2025 (line 243 of prisma-adapter/dist/index.mjs), so the request likely completes successfully. However, the error still surfaces in logs via Prisma's built-in client logger (log: ['error']), which fires before the application-level catch.

To Reproduce

  1. Configure better-auth with @better-auth/passkey and prismaAdapter
  2. Enable Prisma client logging: new PrismaClient({ log: ['error'] })
  3. Attempt passkey authentication when the verification record is near its expiration time (or under load where the verify step takes a couple seconds)
  4. Observe prisma:error in logs:
prisma:error
Invalid `prisma.verification.delete()` invocation:
An operation failed because it depends on one or more records that were required but not found. No record was found for a delete.

Root Cause

In internal-adapter.mjs, findVerificationValue() (line ~575-589):

// Find the verification record
let verification = await findByIdentifier(storedIdentifier);

// Cleanup expired records — this can delete the record we just found!
if (!options.verification?.disableCleanup) await deleteManyWithHooks([{
    field: "expiresAt",
    value: new Date(),
    operator: "lt"
}], "verification", void 0);

return verification[0] || null; // Returns stale reference to deleted record

Then in @better-auth/passkey/dist/index.mjs, verifyPasskeyAuthentication (line ~372):

const data = await ctx.context.internalAdapter.findVerificationValue(verificationToken);
// ... passkey verification logic (takes time) ...
await ctx.context.internalAdapter.deleteVerificationValue(data.id); // P2025 — already deleted by cleanup

Suggested Fix

findVerificationValue should exclude the found record from cleanup, or the cleanup should run before the find. For example:

// Run cleanup first
if (!options.verification?.disableCleanup) await deleteManyWithHooks([...]);

// Then find (will only return non-expired records)
let verification = await findByIdentifier(storedIdentifier);
return verification[0] || null;

Alternatively, deleteVerificationValue could use deleteMany instead of delete to avoid the not-found error entirely.

Current vs. Expected behavior

Current: findVerificationValue returns a record, then its own cleanup deletes that record, causing deleteVerificationValue to throw P2025.

Expected: If findVerificationValue returns a record, that record should still be deletable by deleteVerificationValue called shortly after.

What version of Better Auth are you using?

1.5.0-beta.10

Which area(s) are affected?

  • Package (@better-auth/passkey)
  • Core (internal-adapter)

Workaround

Set verification: { disableCleanup: true } in better-auth options to prevent the automatic cleanup race. Manage verification record cleanup separately.

  • #6816 — fixed challengeId vs data.id in passkey deleteVerificationValue (already included in our version)
  • #7129 — Prisma adapter P2025 handling for Prisma 7
  • #6267 — Similar P2025 issue with email OTP verification deletion
  • #8313 — verification.delete() using non-unique identifier in WhereUniqueInput
Originally created by @skgbafa on GitHub (Mar 4, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8376 ### Is this suited for github? - [x] Yes, this is suited for github ### Bug Description During `POST /api/auth/passkey/verify-authentication`, `findVerificationValue()` runs an automatic cleanup of expired verification records **before** returning the found record. If the found record is at or past its expiration boundary, the cleanup deletes it. The verification flow then continues (the challenge data is already in memory), but when `deleteVerificationValue(data.id)` is called at the end, the record no longer exists, causing a Prisma `P2025` error. The Prisma adapter does catch `P2025` (line 243 of `prisma-adapter/dist/index.mjs`), so the request likely completes successfully. However, the error still surfaces in logs via Prisma's built-in client logger (`log: ['error']`), which fires before the application-level catch. ### To Reproduce 1. Configure better-auth with `@better-auth/passkey` and `prismaAdapter` 2. Enable Prisma client logging: `new PrismaClient({ log: ['error'] })` 3. Attempt passkey authentication when the verification record is near its expiration time (or under load where the verify step takes a couple seconds) 4. Observe `prisma:error` in logs: ``` prisma:error Invalid `prisma.verification.delete()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for a delete. ``` ### Root Cause In `internal-adapter.mjs`, `findVerificationValue()` (line ~575-589): ```js // Find the verification record let verification = await findByIdentifier(storedIdentifier); // Cleanup expired records — this can delete the record we just found! if (!options.verification?.disableCleanup) await deleteManyWithHooks([{ field: "expiresAt", value: new Date(), operator: "lt" }], "verification", void 0); return verification[0] || null; // Returns stale reference to deleted record ``` Then in `@better-auth/passkey/dist/index.mjs`, `verifyPasskeyAuthentication` (line ~372): ```js const data = await ctx.context.internalAdapter.findVerificationValue(verificationToken); // ... passkey verification logic (takes time) ... await ctx.context.internalAdapter.deleteVerificationValue(data.id); // P2025 — already deleted by cleanup ``` ### Suggested Fix `findVerificationValue` should exclude the found record from cleanup, or the cleanup should run **before** the find. For example: ```js // Run cleanup first if (!options.verification?.disableCleanup) await deleteManyWithHooks([...]); // Then find (will only return non-expired records) let verification = await findByIdentifier(storedIdentifier); return verification[0] || null; ``` Alternatively, `deleteVerificationValue` could use `deleteMany` instead of `delete` to avoid the not-found error entirely. ### Current vs. Expected behavior **Current**: `findVerificationValue` returns a record, then its own cleanup deletes that record, causing `deleteVerificationValue` to throw P2025. **Expected**: If `findVerificationValue` returns a record, that record should still be deletable by `deleteVerificationValue` called shortly after. ### What version of Better Auth are you using? 1.5.0-beta.10 ### Which area(s) are affected? - [x] Package (`@better-auth/passkey`) - [x] Core (`internal-adapter`) ### Workaround Set `verification: { disableCleanup: true }` in better-auth options to prevent the automatic cleanup race. Manage verification record cleanup separately. ### Related Issues - #6816 — fixed `challengeId` vs `data.id` in passkey deleteVerificationValue (already included in our version) - #7129 — Prisma adapter P2025 handling for Prisma 7 - #6267 — Similar P2025 issue with email OTP verification deletion - #8313 — verification.delete() using non-unique identifier in WhereUniqueInput
GiteaMirror added the credentialssecuritybug labels 2026-04-17 19:51:19 -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#28396