002: Implement OAuth2 Refresh Token Rotation for OIDC Authentication
PROPOSED
- Date: 2026-01-15
- Tags: authentication, oauth2, oidc, security, better-auth, refresh-tokens, two-circles, kore
- Author: Ivo van der Knaap
- Decision-making style: PROPOSAL
Context
Our application requires secure, long-lived authentication sessions against an external OIDC identity provider (KORE/Two Circles). The current architecture, documented in ADR 001, explicitly deferred OIDC refresh token rotation with the rationale that "we do not consume or store access tokens for external APIs."
This decision has been revisited because:
We now consume access tokens for external API calls: The GraphQL API validates access tokens via OIDC introspection, and the
@repo/api-sdksends Bearer tokens to protected endpoints.Access tokens have limited lifetimes: OIDC access tokens typically expire within 15-60 minutes. Without refresh token rotation, users are forced to re-authenticate frequently, degrading user experience.
Cross-platform consistency: The React Native mobile app already implements refresh token rotation via
TokenRefreshManager. The web app lacks this capability, creating inconsistent behavior.Security best practices: OAuth 2.1 and current security guidance strongly recommend refresh token rotation to mitigate token theft and replay attacks.
Current Architecture
| Component | Technology | Token Handling |
|---|---|---|
| Web App | Next.js 16 with better-auth | Stores accessToken in JWE-encrypted session cookie; no refresh token handling |
| Native App | React Native/Expo with expo-auth-session | Full refresh token rotation via TokenRefreshManager |
| Shared SDK | @repo/api-sdk (Apollo Client) | Accepts accessToken, adds to Authorization header |
| GraphQL API | Node.js | Validates tokens via OIDC introspection/JWKS |
| OIDC Provider | KORE/Two Circles | Standard OAuth2/OIDC with refresh token support |
Technical Constraint
better-auth's genericOAuth plugin does NOT support refresh token rotation out of the box. The refreshAccessToken function is only available for built-in social providers (Google, Facebook, GitHub, etc.), not custom OIDC providers configured via the generic OAuth plugin.
From better-auth documentation and GitHub issues:
"The
refreshAccessTokenfunction is supported only for built-in social providers. This functionality is not available for custom OAuth providers configured through the Generic OAuth Plugin."
This constraint necessitates a custom implementation.
Drivers
- Security: Refresh tokens must never be exposed to the browser; all refresh operations must occur server-side to prevent XSS-based token theft.
- User Experience: Users should remain authenticated for extended periods (7+ days) without manual re-authentication, while maintaining security through short-lived access tokens.
- Cross-Platform Consistency: Web and native apps should behave identically regarding session duration and token refresh behavior.
- Stateless Design: The solution should not require server-side session storage (Redis, database) beyond what better-auth already provides via encrypted cookies.
- Minimal Invasiveness: Changes should work within better-auth's architecture without forking or heavily patching the library.
- Simplicity: Prefer a minimal, focused implementation over complex middleware or hook-based solutions.
Options Considered
- Option 1 (SELECTED): Server-Side Token Refresh with Custom Callbacks
- Option 2: Client-Side Token Refresh
- Option 3: Backend-for-Frontend (BFF) Pattern
- Option 4: Migrate to NextAuth v5
- Option 5: Fork better-auth and Add Generic OAuth Refresh Support
- Option 6: Accept Current Limitation (No Refresh)
Consequences
Option 1 (SELECTED): Server-Side Token Refresh via Server Action
Implement custom refresh logic using:
- better-auth's
getUserInfoandmapProfileToUsercallbacks to capture and store refresh tokens - A single server action
getAccessToken()that handles token retrieval and refresh - Token refresh service with request deduplication
Benefits:
- Refresh tokens remain server-side only (stored in JWE-encrypted cookie), never exposed to JavaScript
- Works within better-auth's existing architecture without library modifications
- Maintains stateless design—no external session store required
- Simple, minimal API surface—single function handles all token operations
- Supports refresh token rotation when provider issues new refresh tokens
- Request deduplication prevents concurrent refresh attempts
Drawbacks:
- Requires custom implementation and maintenance
- Callers must use
getAccessToken()instead of reading token directly from session
Neutral Facts:
- Requires
offline_accessscope to be configured on OIDC provider - Access token lifetime should be short (15-60 minutes) as per security best practices
- Refresh token lifetime can be 7-30 days
- Requires
Option 2: Client-Side Token Refresh
Implement refresh logic directly in the browser using a React hook that calls the OIDC provider's token endpoint.
Benefits:
- Simpler server-side implementation
- Refresh happens transparently during client activity
- Reduces server load for refresh operations
Drawbacks:
- Major security risk: Refresh tokens exposed to browser JavaScript, vulnerable to XSS attacks
- Requires storing refresh token in accessible location (localStorage, memory)
- Violates OAuth2 security best practices for public clients
- Refresh token can be exfiltrated by malicious scripts
Neutral Facts:
- Would require significant client-side state management
- Similar to some SPA patterns, but not recommended for applications with server-side rendering capability
Option 3: Backend-for-Frontend (BFF) Pattern
Create a dedicated authentication service that handles all token operations, keeping tokens entirely off the Next.js app.
Benefits:
- Maximum security—tokens never leave the BFF service
- Clear separation of concerns
- Can serve multiple frontend applications
- Easier to implement advanced security features (token binding, mTLS)
Drawbacks:
- Significant infrastructure overhead—requires additional service deployment
- Increases system complexity and operational burden
- Adds network latency for all authenticated requests
- Overkill for current scale and requirements
Neutral Facts:
- Common pattern in enterprise environments
- Would require architectural redesign beyond authentication
- Better suited if we had multiple frontend applications
Option 4: Migrate to NextAuth v5
Replace better-auth with NextAuth v5, which has built-in support for custom OIDC providers with refresh token rotation via the jwt callback.
Benefits:
- Native refresh token support for custom OIDC providers
- Well-documented pattern with extensive community resources
- Active development and maintenance
- Established ecosystem of adapters and plugins
Drawbacks:
- NextAuth v5 is still in beta (as of January 2025)
- Requires significant migration effort from better-auth
- Breaking changes possible before stable release
- Different API patterns would require updating all auth-consuming code
Neutral Facts:
- Original ADR 001 considered NextAuth v5 but rejected due to beta status
- Migration would affect multiple files across the codebase
- Would be worth reconsidering once v5 reaches stable release
Option 5: Fork better-auth and Add Generic OAuth Refresh Support
Fork the better-auth repository and implement refresh token support for the genericOAuth plugin.
Benefits:
- Full control over implementation
- Could contribute back to upstream project
- Tailored exactly to our requirements
Drawbacks:
- Ongoing maintenance burden for fork
- Risk of diverging from upstream updates
- Requires deep understanding of better-auth internals
- Community may not accept contribution if design differs from maintainers' vision
Neutral Facts:
- better-auth has open GitHub issues requesting this feature
- OAuth 2.1 compliance efforts are underway in the project
Option 6: Accept Current Limitation (No Refresh)
Maintain the current implementation without refresh token rotation, relying on better-auth's session cookie rotation.
Benefits:
- No implementation effort required
- Simpler architecture
- Current approach works for basic use cases
Drawbacks:
- Users must re-authenticate when access token expires (potentially every 15-60 minutes)
- Poor user experience for extended sessions
- Inconsistent with native app behavior
- Access tokens may become invalid during active use, causing failed API calls
Neutral Facts:
- This was the original decision in ADR 001
- Acceptable only if we don't need long-lived access tokens for API calls
Implementation Overview (Option 1)
File Structure
apps/web/
├── lib/auth/
│ ├── auth.ts # Modify: capture refresh token in session
│ └── token-refresh.ts # Create: token refresh service
├── actions/auth/
│ └── refresh-token.ts # Create: getAccessToken() server action
└── types/
└── better-auth.ts # Modify: infer types from auth configPhase 1: Capture Refresh Token in better-auth Configuration
Modify apps/web/lib/auth/auth.ts:
- Capture
refreshTokenandaccessTokenExpiresAtingetUserInfocallback - Store these values via
mapProfileToUserin the session - Add user fields:
refreshToken(string),accessTokenExpiresAt(number)
Phase 2: Create Token Refresh Service
Create apps/web/lib/auth/token-refresh.ts:
isTokenExpiringSoon(expiresAt): Check if token needs refresh (5-minute buffer)refreshAccessToken(refreshToken): Call OIDC provider's token endpointrefreshAccessTokenWithDedup(refreshToken): Deduplicate to prevent concurrent refreshes- Custom error classes:
RefreshTokenReuseError,RefreshTokenExpiredError
Phase 3: Server Action for Token Access
Create apps/web/actions/auth/refresh-token.ts:
- Single function:
getAccessToken(): Promise<string | null> - Return valid token immediately if not expiring
- Automatically refresh if token expires within 5 minutes
- Update session with new tokens via
auth.api.updateUser() - Return
nullif not authenticated or refresh fails
Usage:
import { getAccessToken } from "@/actions/auth/refresh-token";
const accessToken = await getAccessToken();
if (!accessToken) {
// Not authenticated or session expired
}Error Handling
| Scenario | Handling |
|---|---|
| Concurrent refresh requests | Deduplication via singleton promise |
Refresh token reuse detection (invalid_grant) | Returns null, logs security warning |
| Refresh token expired | Returns null, logs warning |
| Network/other errors | Returns null, logs error |
OIDC Provider Configuration Required
- Enable "Rotate Refresh Tokens" setting (if available)
- Add
offline_accessto requested scopes - Configure access token lifetime: 15-60 minutes (recommended)
- Configure refresh token lifetime: 7-30 days
- Enable refresh token reuse detection if available
Advice
[To be filled in during review process]
Supporting Material
- OAuth 2.0 Security Best Current Practice (RFC 6819)
- OAuth 2.1 Draft Specification
- better-auth Documentation - Generic OAuth Plugin
- better-auth GitHub Issue - Refresh Token Support for Generic OAuth
- better-auth GitHub Issue - OAuth 2.1 Compliance
- NextAuth.js v5 - Refresh Token Rotation
- OWASP - Token Best Practices
- Auth0 - Refresh Token Rotation
- ADR 001 - Authentication Strategy