OAuth Provider Token Lifecycle#
When a user authenticates via an upstream OAuth/OIDC provider (Google, GitHub, etc.), Better Auth stores the provider-issued tokens in the account table, creates its own independent session, and exposes client-initiated endpoints for downstream token refresh. Better Auth's session lifetime is not coupled to the upstream provider's token expiry or revocation events — there is no built-in back-channel or front-channel logout integration.
Key source files:
| File | Role |
|---|---|
src/api/routes/callback.ts | /callback/:id — token exchange, user lookup, session creation |
src/oauth2/link-account.ts | handleOAuthUserInfo() — sign-in vs. register, account upsert |
src/api/routes/account.ts | /get-access-token and /refresh-token endpoints |
src/oauth2/utils.ts | setTokenUtil() / decryptOAuthToken() — optional token encryption |
Token Storage#
All upstream OAuth tokens are stored in the account table (one row per provider per user). The relevant fields :
| Field | Type | Notes |
|---|---|---|
accessToken | string? | Not returned in API responses |
refreshToken | string? | Not returned in API responses |
idToken | string? | Not returned in API responses |
accessTokenExpiresAt | date? | Not returned in API responses |
refreshTokenExpiresAt | date? | Not returned in API responses |
scope | string? | Returned in API responses |
All five token columns carry returned: false, which causes parseAccountOutput() to strip them from any API response — preventing accidental token exposure.
Optional encryption. When encryptOAuthTokens is enabled, tokens are encrypted at rest via setTokenUtil() before being written, and decrypted with decryptOAuthToken() before use.
When tokens are written:
- New user registration —
userandaccountrows are created atomically in a transaction bycreateOAuthUser() - Existing account sign-in —
updateAccount()writes fresh tokens whenaccount.updateAccountOnSignInis notfalse - Account linking —
linkAccount()writes tokens for the newly linked provider account
Known quirk: If the upstream provider omits expires_in from its token response, accessTokenExpiresAt is undefined. Because the update object filters out undefined values before writing to the database, the expiry timestamp is silently left stale on re-sign-in .
Session Creation After OAuth Callback#
The /callback/:id endpoint handles responses from every social/OAuth provider . The simplified flow:
Provider redirect → /callback/:id
1. Exchange code → tokens (validateAuthorizationCode)
2. Fetch user profile (getUserInfo)
3. handleOAuthUserInfo() → upsert user + account rows, store tokens
4. internalAdapter.createSession(user.id) ← Better Auth session
5. Set session cookie → redirect
The session created in step 4 is keyed only to user.id — it has no reference to the upstream access or refresh token . Its lifetime is governed by Better Auth's own session.expiresIn configuration, not by the upstream token's exp.
Redirect destination after callback :
- New user →
newUserURLif configured, otherwisecallbackURL - Returning user →
callbackURL
Explicit account linking (when the OAuth state carries a link flag) skips session creation entirely — the flow updates/creates the account row and redirects back without touching the session .
Client-Initiated Token Refresh#
Two endpoints in src/api/routes/account.ts expose on-demand refresh of upstream provider tokens. Neither runs automatically — the caller (client or server-side code) must invoke them explicitly.
GET /get-access-token#
Delegates to getValidAccessToken(). If the stored access token is still valid (not within 5 seconds of expiry), it is returned as-is. If expired, the endpoint decrypts the refresh token and calls provider.refreshAccessToken() before returning. Accepts providerId, optional accountId, optional userId in the request body. Returns: accessToken, accessTokenExpiresAt, scopes (as an array), idToken .
POST /refresh-token#
/refresh-token forces an unconditional refresh regardless of token expiry. It validates that the provider supports refresh, decrypts the stored refresh token, calls provider.refreshAccessToken(decryptedRefreshToken), persists the new tokens, and returns the full updated token set including refreshToken, refreshTokenExpiresAt, providerId, and accountId .
After every refresh#
Both paths call internalAdapter.updateAccount() to persist the new tokens . If storeAccountCookie is enabled, the account cookie is also updated via setAccountCookie() with a 5-minute TTL .
Error codes#
| Error | Condition |
|---|---|
PROVIDER_NOT_SUPPORTED | Provider not found in config |
TOKEN_REFRESH_NOT_SUPPORTED | Provider has no refreshAccessToken implementation |
REFRESH_TOKEN_NOT_FOUND | No refresh token stored for this account |
FAILED_TO_REFRESH_ACCESS_TOKEN | Provider call returned an error |
Session Decoupling from Upstream Provider Lifecycle#
Better Auth's session is architecturally independent of the upstream OAuth provider's token lifecycle. There is no built-in mechanism that invalidates a Better Auth session because the upstream provider's access or refresh token has expired or been revoked.
What is absent:
- No OIDC back-channel logout — upstream providers cannot push a logout signal to Better Auth. (The
oauth-providerplugin, when Better Auth acts as the authorization server, has its own session-delete hook that revokes session-bound tokens — but that is a different concern from upstream-provider-initiated logout .) - No front-channel logout — no endpoint listens for OIDC
logout_tokenor session-iframe messages from upstream providers. - No periodic/automatic token refresh — upstream token validity is checked only when
/get-access-tokenor/refresh-tokenis explicitly called. A revoked upstream refresh token is not detected until the next explicit call.
Practical consequence: A user who revokes Better Auth's access at the upstream provider (e.g., removes the app from their Google account) retains a fully valid Better Auth session until that session's own expiry. The account row still holds a stale (now-invalid) refresh token.
Workaround (custom implementation): On each sensitive request, call auth.api.getAccessToken({ providerId }). If the call returns FAILED_TO_REFRESH_ACCESS_TOKEN (refresh token revoked by provider), explicitly revoke the Better Auth session. Setting session.expiresIn to match the upstream refresh token lifetime provides a hard upper bound, but does not react to revocation.
Open issues tracking this gap: