OAuth Refresh Token Session Binding#
What This Is#
When an OAuth client refreshes tokens, the replacement access and refresh tokens inherit a sid (session ID) claim from the originating refresh token row. In handleRefreshTokenGrant, that session ID is copied forward without verifying that the session is still alive. This means a client can hold a refresh token that, on use, produces new tokens whose sid points to an expired or missing Better Auth session — tokens that introspection will immediately report as inactive.
The Bug#
Affected versions: @better-auth/oauth-provider@1.7.1 and main at 93e9143fe8d5d623500db6e01646e8bf11ed29a5
Root Cause#
handleRefreshTokenGrant passes refreshToken.sessionId directly as the sessionId parameter to createUserTokens . From there the value flows into:
- JWT access token — as the
sidclaim increateJwtAccessToken - Opaque access token — as
sessionIdstored on theoauthAccessTokenrow - Replacement refresh token —
createRefreshTokenreadspayload.sidand writes it into the new row'ssessionId
At no point in this chain is the session looked up or its expiresAt checked .
Why Session Deletion Doesn't Always Surface It#
The oauthRefreshToken.sessionId FK is declared onDelete: "set null" , so when a session row is deleted the field is nulled out by the adapter. The bug is most reproducible by expiring the session in place (updating expiresAt to the past) rather than deleting it — that leaves the session ID on the refresh token row, which then flows into fresh tokens .
Inconsistency with the Rest of the Codebase#
| Path | Session liveness check? |
|---|---|
handleAuthorizationCodeGrant — initial issuance | ✅ Throws invalid_request if session missing or expired |
handleRefreshTokenGrant — token refresh | ❌ None |
validateJwtAccessToken — introspection | ✅ Returns active: false if session missing or expired |
validateOpaqueAccessToken — introspection | ✅ Returns active: false if session missing or expired |
validateRefreshToken — introspection | ✅ Nulls sid if session missing or expired |
Because introspection checks session liveness but issuance does not, tokens minted by a refresh can be immediately inactive on their first introspection call .
Impact#
- Functional breakage: Freshly issued access tokens are reported inactive by introspection if their
sidreferences an expired session, even within their own TTL. - Security concern: Replacement tokens carry an
sidthat is false — it does not represent an active authentication event, potentially misleading resource servers that rely onsidfor session-bound authorization decisions. - Scope: Affects all refresh flows where the client used
offline_accessand the original issuance session has expired without being deleted.
Proposed Fix (PR #11133)#
PR #11133 (fix(oauth-provider): detach inactive sessions on refresh) — currently open — adds a session liveness check inside handleRefreshTokenGrant before calling createUserTokens:
- Derive
sessionIdfromrefreshToken.sessionId. - Look it up via
ctx.context.adapter.findOne<Session>. - If the session is missing or
session.expiresAt < new Date(), setsessionId = undefined. - Pass the resolved value (live session ID or
undefined) tocreateUserTokens.
This matches the pattern already used in handleAuthorizationCodeGrant and the introspection validators. The offline refresh grant itself remains valid — the session detachment only affects the sid claim on newly minted tokens; it does not revoke the grant.
Test coverage in token.test.ts covers three scenarios: active session (preserves sid), expired session (omits sid), and deleted session (omits sid) .
Key Source Files#
| File | Relevance |
|---|---|
packages/oauth-provider/src/token.ts | handleRefreshTokenGrant (line 1782), createUserTokens (line 1094), createRefreshToken (line 593) |
packages/oauth-provider/src/introspect.ts | validateJwtAccessToken, validateOpaqueAccessToken, validateRefreshToken — show where liveness checks do exist |
packages/oauth-provider/src/schema.ts | oauthRefreshToken.sessionId FK with onDelete: "set null" |
| Issue #11132 | Bug report and confirmed analysis |
| PR #11133 | Pending fix |