Skip to content

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:

  1. We now consume access tokens for external API calls: The GraphQL API validates access tokens via OIDC introspection, and the @repo/api-sdk sends Bearer tokens to protected endpoints.

  2. 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.

  3. Cross-platform consistency: The React Native mobile app already implements refresh token rotation via TokenRefreshManager. The web app lacks this capability, creating inconsistent behavior.

  4. Security best practices: OAuth 2.1 and current security guidance strongly recommend refresh token rotation to mitigate token theft and replay attacks.

Current Architecture

ComponentTechnologyToken Handling
Web AppNext.js 16 with better-authStores accessToken in JWE-encrypted session cookie; no refresh token handling
Native AppReact Native/Expo with expo-auth-sessionFull refresh token rotation via TokenRefreshManager
Shared SDK@repo/api-sdk (Apollo Client)Accepts accessToken, adds to Authorization header
GraphQL APINode.jsValidates tokens via OIDC introspection/JWKS
OIDC ProviderKORE/Two CirclesStandard 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 refreshAccessToken function 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 getUserInfo and mapProfileToUser callbacks 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_access scope 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

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 config

Phase 1: Capture Refresh Token in better-auth Configuration

Modify apps/web/lib/auth/auth.ts:

  • Capture refreshToken and accessTokenExpiresAt in getUserInfo callback
  • Store these values via mapProfileToUser in 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 endpoint
  • refreshAccessTokenWithDedup(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 null if not authenticated or refresh fails

Usage:

typescript
import { getAccessToken } from "@/actions/auth/refresh-token";

const accessToken = await getAccessToken();
if (!accessToken) {
	// Not authenticated or session expired
}

Error Handling

ScenarioHandling
Concurrent refresh requestsDeduplication via singleton promise
Refresh token reuse detection (invalid_grant)Returns null, logs security warning
Refresh token expiredReturns null, logs warning
Network/other errorsReturns null, logs error

OIDC Provider Configuration Required

  1. Enable "Rotate Refresh Tokens" setting (if available)
  2. Add offline_access to requested scopes
  3. Configure access token lifetime: 15-60 minutes (recommended)
  4. Configure refresh token lifetime: 7-30 days
  5. Enable refresh token reuse detection if available

Advice

[To be filled in during review process]

Supporting Material