[GH-ISSUE #7725] Passkey plugin: expirationTime computed once at init, not per-request #19517

Closed
opened 2026-04-15 18:43:58 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @mxzinke on GitHub (Jan 31, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7725

Originally assigned to: @bytaesu on GitHub.

Description

In packages/passkey/src/index.ts, the passkey() factory function computes expirationTime once at plugin initialization:

export const passkey = (options?: PasskeyOptions | undefined) => {
  const opts = { ... };
  const expirationTime = new Date(Date.now() + 1000 * 60 * 5);
  const currentTime = new Date();
  const maxAgeInSeconds = Math.floor(
    (expirationTime.getTime() - currentTime.getTime()) / 1000,
  );

  return {
    id: "passkey",
    endpoints: {
      generatePasskeyRegistrationOptions: generatePasskeyRegistrationOptions(
        opts, { maxAgeInSeconds, expirationTime }
      ),
      generatePasskeyAuthenticationOptions: generatePasskeyAuthenticationOptions(
        opts, { maxAgeInSeconds, expirationTime }
      ),
      // ...
    },
  };
};

Both expirationTime and currentTime are computed once when passkey() is called (server startup), not per-request.

Impact

  • maxAgeInSeconds (~300): No issue — it's a relative duration used for cookie max-age, which is always correct.
  • expirationTime: This is an absolute Date passed to createVerificationValue({ expiresAt: expirationTime }). After the server has been running for 5+ minutes, all newly created passkey verification records will have an expiresAt timestamp in the past.
  • Race condition: Since findVerificationValue runs lazy cleanup (DELETE WHERE expiresAt < NOW()), one user's verification can accidentally clean up another user's in-flight challenge. This race becomes much more likely when all records are immediately expired.
  • Mitigated by cookies: The cookie maxAge: 300 provides the actual timeout enforcement, so passkey flows don't fail outright. But the stale DB expiresAt causes unnecessary cleanup of valid in-flight challenges.

Suggested fix

Compute expirationTime per-request inside the endpoint handler rather than in the factory function:

// Inside generatePasskeyRegistrationOptions / generatePasskeyAuthenticationOptions handler:
const expirationTime = new Date(Date.now() + 1000 * 60 * 5);

Or pass a function/getter instead of a static value:

generatePasskeyRegistrationOptions(opts, {
  maxAgeInSeconds,
  getExpirationTime: () => new Date(Date.now() + 1000 * 60 * 5),
}),

Version

@better-auth/passkey@1.4.18 — bug also present on canary branch as of 2025-01-31.

Originally created by @mxzinke on GitHub (Jan 31, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7725 Originally assigned to: @bytaesu on GitHub. ## Description In `packages/passkey/src/index.ts`, the `passkey()` factory function computes `expirationTime` once at plugin initialization: ```ts export const passkey = (options?: PasskeyOptions | undefined) => { const opts = { ... }; const expirationTime = new Date(Date.now() + 1000 * 60 * 5); const currentTime = new Date(); const maxAgeInSeconds = Math.floor( (expirationTime.getTime() - currentTime.getTime()) / 1000, ); return { id: "passkey", endpoints: { generatePasskeyRegistrationOptions: generatePasskeyRegistrationOptions( opts, { maxAgeInSeconds, expirationTime } ), generatePasskeyAuthenticationOptions: generatePasskeyAuthenticationOptions( opts, { maxAgeInSeconds, expirationTime } ), // ... }, }; }; ``` Both `expirationTime` and `currentTime` are computed once when `passkey()` is called (server startup), not per-request. ## Impact - **`maxAgeInSeconds` (~300)**: No issue — it's a relative duration used for cookie max-age, which is always correct. - **`expirationTime`**: This is an absolute `Date` passed to `createVerificationValue({ expiresAt: expirationTime })`. After the server has been running for 5+ minutes, all newly created passkey verification records will have an `expiresAt` timestamp in the past. - **Race condition**: Since `findVerificationValue` runs lazy cleanup (`DELETE WHERE expiresAt < NOW()`), one user's verification can accidentally clean up another user's in-flight challenge. This race becomes much more likely when all records are immediately expired. - **Mitigated by cookies**: The cookie `maxAge: 300` provides the actual timeout enforcement, so passkey flows don't fail outright. But the stale DB `expiresAt` causes unnecessary cleanup of valid in-flight challenges. ## Suggested fix Compute `expirationTime` per-request inside the endpoint handler rather than in the factory function: ```ts // Inside generatePasskeyRegistrationOptions / generatePasskeyAuthenticationOptions handler: const expirationTime = new Date(Date.now() + 1000 * 60 * 5); ``` Or pass a function/getter instead of a static value: ```ts generatePasskeyRegistrationOptions(opts, { maxAgeInSeconds, getExpirationTime: () => new Date(Date.now() + 1000 * 60 * 5), }), ``` ## Version `@better-auth/passkey@1.4.18` — bug also present on `canary` branch as of 2025-01-31.
GiteaMirror added the lockedbug labels 2026-04-15 18:43:58 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 31, 2026):

It looks like you've identified a significant issue with the expirationTime in the Passkey plugin, where it's only computed once at initialization rather than per request. This can indeed lead to problems, especially with expired timestamps after some time has passed since the server started, resulting in potential race conditions.

Your suggested fix to compute expirationTime within the endpoint handler seems appropriate. Implementing it per request would ensure that the timestamps are accurate for every user session, avoiding the unintended cleanup of valid challenges. By updating the code where you compute expirationTime within the endpoint handlers, you can mitigate these issues effectively.

If you want to go ahead with the implementation, consider these adjustments:

  1. Calculate expirationTime and currentTime at the start of the request in the endpoint handler.
  2. You could also pass a function to get the expirationTime dynamically instead of passing a static value, which would enhance flexibility.

If you need further information or assistance with the implementation, feel free to ask! Have you already tried modifying the code as suggested, or do you have other questions regarding this?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3828798166 --> @better-auth-agent[bot] commented on GitHub (Jan 31, 2026): It looks like you've identified a significant issue with the `expirationTime` in the Passkey plugin, where it's only computed once at initialization rather than per request. This can indeed lead to problems, especially with expired timestamps after some time has passed since the server started, resulting in potential race conditions. Your suggested fix to compute `expirationTime` within the endpoint handler seems appropriate. Implementing it per request would ensure that the timestamps are accurate for every user session, avoiding the unintended cleanup of valid challenges. By updating the code where you compute `expirationTime` within the endpoint handlers, you can mitigate these issues effectively. If you want to go ahead with the implementation, consider these adjustments: 1. Calculate `expirationTime` and `currentTime` at the start of the request in the endpoint handler. 2. You could also pass a function to get the `expirationTime` dynamically instead of passing a static value, which would enhance flexibility. If you need further information or assistance with the implementation, feel free to ask! Have you already tried modifying the code as suggested, or do you have other questions regarding this? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19517