# Better Auth: OAuth refresh-token rotation forks the token family on concurrent redemption

Non-atomic refresh-token rotation in Better Auth's OAuth provider lets two concurrent redemptions of one token both mint new tokens, forking the family and granting indefinite access.

- Payout: Research
- Severity: high
- Program: Better Auth
- Found by: chdanielmueller
- Disclosed: 2026-07-07
- CVE: CVE-2026-53517
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-392p-2q2v-4372
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-392p-2q2v-4372
- Tags: area:web domain:api domain:web impact:ato impact:integrity kind:research platform:ghsa sev:high tech:oauth technique:race-window technique:source-review vuln:auth-bypass vuln:race-condition

**Target:** OAuth authorization server `POST /oauth2/token` refresh_token grant
**Root cause:** The read/validate/revoke/mint sequence on the `oauthRefreshToken` row is non-atomic: the `adapter.update` predicate keys only on `id` without `revoked IS NULL`, and `oauthRefreshToken.token` lacks a unique constraint, so concurrent redemptions both pass the revocation check and each mint a fresh token.
**Steps:**
1. Client requests `offline_access` scope so refresh tokens are minted.
2. Attacker (or honest client) sends two concurrent `POST /oauth2/token` requests with `grant_type=refresh_token` and the same parent refresh token.
3. Both requests read the row with `revoked` falsy and pass the replay-detection check.
4. Both `adapter.update` calls succeed (predicate on `id` only, last-write-wins) and both mint new refresh tokens.
5. Result: forked refresh-token family; replaying the original token later does not trip family invalidation because the parent row was swapped.
**How found:** Code review of the rotation primitive: noticed the `adapter.update` predicate omitted `revoked IS NULL` and the schema lacked a unique constraint on `oauthRefreshToken.token`, making the check-then-consume sequence racy against RFC 9700 §4.14 family invalidation.
**Exploitation:** An attacker holding a stolen refresh token times two concurrent redemption requests so both pass the revocation check, obtaining two valid branches of the token family. Each rotation refreshes the 7-day `refreshTokenExpiresAt` window, so access persists indefinitely at the victim's authorization scope even if one branch is revoked.
**Escalation:** Forked families survive single revocation and bypass family-invalidation detection, since the attacker's branch swaps the parent row before the legitimate user's check.
**Insight:** When auditing OAuth refresh-token rotation, check whether the revoke/consume step is a conditional write (CAS with rowcount check) or a plain update keyed on id; also check for a unique index on the token column, since its absence lets concurrent creates silently fork the family.

