[GH-ISSUE #9096] fix(oauth-provider): make JTI replay protection atomic across instances #11271

Open
opened 2026-04-13 07:37:10 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @gustavovalverde on GitHub (Apr 10, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9096

Problem

The private_key_jwt JTI replay protection in packages/oauth-provider/src/utils/client-assertion.ts uses a non-atomic findVerificationValue + createVerificationValue sequence. In multi-instance deployments, two requests with the same jti can both pass the find check (both return null) and both insert a tombstone, bypassing the single-use guarantee.

The in-memory pendingAssertionIds Set only protects within a single process. A double-check after failed create was added in #8836 as a mitigation, but it does not fully close the race window.

Root cause

The verification store (internalAdapter.createVerificationValue) does not enforce a unique constraint on the identifier column. Without an atomic "create-if-not-exists" or a unique index, the find-then-create pattern has a TOCTOU race.

Proposed fix

Add a unique constraint on verification.identifier in the core schema, so createVerificationValue fails with a constraint violation when a duplicate is inserted. The private_key_jwt code already handles this case (catches createErr and double-checks), so the only change needed is the schema migration.

This would also benefit other verification-store consumers (email verification, password reset) that rely on identifier uniqueness.

Impact

Low practical risk: the race window is milliseconds, the attacker needs the same assertion to hit two different instances simultaneously, and the assertion is already short-lived (default 5 min max). But it violates the OIDC Core Section 9 single-use requirement for jti.

References

  • PR #8836 (private_key_jwt implementation)
  • OIDC Core Section 9: jti must be single-use
  • RFC 7523 Section 3: jti MAY be used for replay prevention
Originally created by @gustavovalverde on GitHub (Apr 10, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9096 ## Problem The `private_key_jwt` JTI replay protection in `packages/oauth-provider/src/utils/client-assertion.ts` uses a non-atomic `findVerificationValue` + `createVerificationValue` sequence. In multi-instance deployments, two requests with the same `jti` can both pass the `find` check (both return null) and both insert a tombstone, bypassing the single-use guarantee. The in-memory `pendingAssertionIds` Set only protects within a single process. A double-check after failed `create` was added in #8836 as a mitigation, but it does not fully close the race window. ## Root cause The verification store (`internalAdapter.createVerificationValue`) does not enforce a unique constraint on the `identifier` column. Without an atomic "create-if-not-exists" or a unique index, the find-then-create pattern has a TOCTOU race. ## Proposed fix Add a unique constraint on `verification.identifier` in the core schema, so `createVerificationValue` fails with a constraint violation when a duplicate is inserted. The `private_key_jwt` code already handles this case (catches `createErr` and double-checks), so the only change needed is the schema migration. This would also benefit other verification-store consumers (email verification, password reset) that rely on identifier uniqueness. ## Impact Low practical risk: the race window is milliseconds, the attacker needs the same assertion to hit two different instances simultaneously, and the assertion is already short-lived (default 5 min max). But it violates the OIDC Core Section 9 single-use requirement for `jti`. ## References - PR #8836 (private_key_jwt implementation) - OIDC Core Section 9: `jti` must be single-use - RFC 7523 Section 3: `jti` MAY be used for replay prevention
GiteaMirror added the security label 2026-04-13 07:37:10 -05:00
Author
Owner

@srimon12 commented on GitHub (Apr 11, 2026):

I think there’s a practical mitigation available for affected deployments even before any internal adapter change lands.

If an app is using the generated verification table, making verification.identifier unique at the database level should close the replay gap for private_key_jwt in practice. The current replay logic already appears to expect duplicate createVerificationValue(...) attempts to fail and then treats that path as a replay signal; the remaining issue is that a non-unique identifier column allows both concurrent inserts to succeed.

So for self-hosted users, an interim mitigation could be:

add a unique constraint / unique index on verification.identifier
clean up any existing duplicate identifier rows before applying it
redeploy so concurrent duplicate assertions fail on insert rather than succeeding on both instances
That seems valuable beyond private_key_jwt as well, since other verification-store use cases also implicitly benefit from identifier uniqueness.

I’m mentioning this as a deployment-level mitigation only, not as a substitute for an upstream fix. The product-side improvement still seems to be making identifier uniqueness an explicit part of the core schema / migration story so the replay behavior is guaranteed by default.

<!-- gh-comment-id:4227950294 --> @srimon12 commented on GitHub (Apr 11, 2026): I think there’s a practical mitigation available for affected deployments even before any internal adapter change lands. If an app is using the generated verification table, making verification.identifier unique at the database level should close the replay gap for private_key_jwt in practice. The current replay logic already appears to expect duplicate createVerificationValue(...) attempts to fail and then treats that path as a replay signal; the remaining issue is that a non-unique identifier column allows both concurrent inserts to succeed. So for self-hosted users, an interim mitigation could be: add a unique constraint / unique index on verification.identifier clean up any existing duplicate identifier rows before applying it redeploy so concurrent duplicate assertions fail on insert rather than succeeding on both instances That seems valuable beyond private_key_jwt as well, since other verification-store use cases also implicitly benefit from identifier uniqueness. I’m mentioning this as a deployment-level mitigation only, not as a substitute for an upstream fix. The product-side improvement still seems to be making identifier uniqueness an explicit part of the core schema / migration story so the replay behavior is guaranteed by default.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#11271