oauth-provider: refresh replay can leave successor usable
Is this suited for GitHub?
- Yes, this is suited for GitHub
Reproduction
A deterministic schedule against PostgreSQL with the Prisma adapter reproduces a refresh-token family invalidation race. Requests still execute through the real /oauth2/token endpoint; the only scheduling control is pausing the adapter create for the successor oauthRefreshToken row.
Configure the OAuth Provider with authorization-code and refresh-token grants, offline_access, rotation, and refreshTokenReuseInterval: 0.
- Obtain refresh token
R0. - Start redemption of
R0as request A. - Allow A to win the guarded parent update that marks
R0rotated/revoked. - Pause A immediately before its successor refresh-token row
R1is inserted. - Redeem
R0again as request B. - B observes the rotated token, calls family invalidation, and returns
invalid_grant. - Resume A. It inserts
R1and returns a successful token response. - Redeem
R1.
Minimal scheduling sketch:
const originalCreate = context.adapter.create.bind(context.adapter);
context.adapter.create = async (input) => {
if (input.model === "oauthRefreshToken" && input.data.clientId === clientId) {
entered();
await resume;
}
return originalCreate(input);
};
const rotating = redeem(R0);
await paused;
expect((await redeem(R0)).status).toBe(400);
release();
const next = await (await rotating).json();
expect((await redeem(next.refresh_token)).status).toBe(400); // receives 200The relevant 1.7.5 source is packages/oauth-provider/src/token.ts. It still documents this interleaving as TODO(invalidate-family-race) and FIXME(strict-family-invalidation): parent compare-and-swap, family deletion, successor insertion, and access-token insertion do not share one database transaction.
Current vs. expected behavior
Expected: after reuse of a rotated refresh token is detected and family invalidation completes, no token in that compromised family can subsequently be used. Redeeming R1 must return invalid_grant.
Actual: request B returns invalid_grant, but request A inserts R1 after B has deleted the currently visible family. R1 then redeems successfully with HTTP 200.
This is deterministic under the schedule above and is distinct from ordinary concurrent rotation, where the guarded parent update correctly chooses one winner.
What version of Better Auth are you using?
Reproduced on [email protected] and @better-auth/[email protected]. The latest 1.7.5 source was reviewed and retains the same non-transactional sequence and explicit race TODO/FIXME. The complete reproduction has not been rerun on 1.7.5.
System info
OS: Darwin 25.6.0 arm64
Node: 22.21.0
pnpm: 11.21.0
Database: PostgreSQL
Adapter: Prisma 6.19.3
Framework: Next.js 16.3.4
Test runner: Vitest 4.1.11Which areas are affected?
- Backend
- Package
Auth config
oauthProvider({
grantTypes: ["authorization_code", "refresh_token"],
scopes: ["openid", "offline_access", "api.read"],
accessTokenExpiresIn: 300,
refreshTokenExpiresIn: 86400,
refreshTokenReuseInterval: 0,
});Additional context
- #10082 fixed the portable guarded compare-and-swap and explicitly identified strict family invalidation as a separate concern.
- #8512 and PR #10145 added an opt-in equivalent-request reuse interval. That addresses retry ergonomics, not the strict replay/family-invalidation guarantee when the interval is zero or replay is outside the interval.
- A process-local mutex would not protect multiple instances. The source comment already points toward an adapter-supported transaction covering the entire mint chain.
The security acceptance criterion is intentionally strict: a successor created after replay invalidation must not remain usable.
Source: better-auth/better-auth