Before we talk about token refresh, we need to talk about architecture. Token strategy is never isolated—it's a consequence of how your system is structured. This is the story of how we simplified our authentication layer by moving responsibility to the right boundary.
The Starting Point: A Modern But Complicated Stack
Our setup was fairly common in the Next.js ecosystem:
- Framework: Next.js with the App Router
- Authentication: NextAuth (Auth.js) handling OAuth flows
- API Consumption: REST client for internal services + Apollo Client for GraphQL APIs
- Rendering: Mix of Server Components and Client Components
The authentication flow looked clean at first glance:
- User signs in through an OAuth provider
- NextAuth stores
accessToken,refreshToken, andaccessTokenExpiresin the JWT - Session lives in an HttpOnly cookie
- Both REST and GraphQL clients use the access token for backend communication
Authentication was centralized through NextAuth, APIs were modularized, and the App Router let us fetch data server-side when needed. Everything looked properly structured.
The problem was subtle: we had two API clients on the frontend that both depended on a valid access token, and we were refreshing that token on the client.
The Initial Strategy: Client-Side Token Management
Our first implementation handled token refresh entirely in the browser. We stored the access token expiry timestamp in the session and implemented logic that:
- Set a timer to refresh the token 5 minutes before expiration
- Triggered refresh on window focus events
- Triggered refresh on visibility change
- Intercepted 401 responses in both REST and Apollo clients
- Attempted token refresh when a 401 occurred
- Retried failed requests after successful refresh
On paper, this looked robust. We had proactive refresh (timers) and reactive refresh (401 handling). We covered multiple edge cases. We had retry logic in the REST client and retry links in Apollo.
In reality, we had distributed token management logic across multiple clients running in an unpredictable environment: the browser.
The Problems We Hit in Production
1. Multi-Tab Race Conditions
If a user opened three tabs and the token was close to expiring, each tab would trigger a refresh around the same time.
Here's where things got messy: many OAuth providers rotate refresh tokens. When you successfully refresh, you get a new refresh token and the old one is invalidated.
- Tab 1: Refresh succeeds ✓
- Tab 2: Attempts refresh with now-invalidated token ✗
- Tab 3: Attempts refresh with now-invalidated token ✗
From the user's perspective, some tabs would randomly log out while others kept working. The UX was unpredictable and confusing.
2. 401 Storms
If a token expired unexpectedly (laptop went to sleep, tab got suspended), multiple API calls would fire when the user returned. All of them would receive 401 responses simultaneously.
Each client would attempt a refresh. Some would retry requests while others were still refreshing. Under load, this created a cascade of retries and duplicated refresh calls. Our frontend was effectively DDoSing our own authentication layer.
3. Stale Sessions and Unreliable Timers
Browser timers are not reliable:
- Tabs get suspended by the browser
- Laptops go to sleep
- Users lose network connectivity
- Background tabs get throttled
When users returned, the token might already be expired. The first API call would fail. The UI would flicker while refresh logic kicked in. Sometimes it would redirect unexpectedly. Sometimes it would partially recover. It never felt deterministic.
4. Architectural Leakage
We were solving an infrastructure concern in the UI layer. Token lifecycle management is not a rendering concern, yet our React codebase contained:
- Focus event listeners
- Visibility change handlers
- Timer management logic
- Retry wrappers around API calls
- Cross-tab synchronization code
The frontend was carrying responsibility that belonged elsewhere.
The Realization
At some point, we asked ourselves a simple question:
Why are we refreshing tokens in the least reliable environment possible?
Let's compare:
Browsers are:
- Unpredictable
- Multi-tabbed
- Suspended randomly
- Rate-limited
- User-controlled
Servers are:
- Deterministic
- Execute once per request
- Centralized
- Easier to reason about
The answer became obvious: move token refresh to the server.
The New Architecture: Server-Driven Token Rotation
We removed all client-side refresh logic:
- ❌ No more timers
- ❌ No more focus listeners
- ❌ No more visibility change handlers
- ❌ No more 401 retry wrappers in the REST client
- ❌ No more Apollo refresh links
Instead, we made the server responsible for token lifecycle.
Implementation Details
1. JWT Callback Handles Refresh
In NextAuth, we moved refresh logic into the JWT callback. This callback runs during session validation:
// Conceptual implementation
async jwt({ token, account }) {
// Initial sign in
if (account) {
return {
accessToken: account.access_token,
refreshToken: account.refresh_token,
accessTokenExpires: account.expires_at * 1000,
}
}
// Return token if it has more than 5 minutes remaining
const REFRESH_BUFFER = 5 * 60 * 1000 // 5 minutes in milliseconds
if (Date.now() < token.accessTokenExpires - REFRESH_BUFFER) {
return token
}
// Token is expired or will expire soon, refresh it
return refreshAccessToken(token)
}When the user logs in, we store credentials in the JWT. On every request that requires session validation, the JWT callback runs. If the token is still valid, we return it as-is. If it's close to expiring, we refresh it on the server before the request proceeds.
We use a 5-minute buffer before expiration to ensure tokens remain valid throughout the request lifecycle. This accounts for:
- Network latency between services
- Clock drift between servers
- Request processing time
Token refresh happens as part of session resolution, not as part of API consumption.
2. Middleware Guards Protected Routes
// middleware.ts
export async function middleware(request: NextRequest) {
const session = await getToken({ req: request })
if (!session) {
return NextResponse.redirect(new URL('/signin', request.url))
}
return NextResponse.next()
}If session validation or refresh fails, middleware redirects to sign-in immediately. There's no partial UI state and no inconsistent client behavior.
3. Backend Calls at Server Boundaries
Data fetching happens in:
- Server Components
- Route Handlers
- Server Actions
In these environments, we retrieve the session using getServerSession(). If the token is expired, the JWT callback refreshes it automatically before the API call is made.
// In a Server Component
async function DashboardPage() {
const session = await getServerSession(authOptions)
const data = await fetchDashboardData(session.accessToken)
return <Dashboard data={data} />
}From the component's perspective, nothing special happens. It simply receives data.
What This Fixed
Multi-Tab Race Conditions → Gone
Each request handles its own token validation on the server. Even if multiple tabs trigger requests simultaneously, refresh logic executes in a controlled environment rather than competing browser contexts.
401 Storms → Gone
Backend API calls no longer receive expired tokens. The server ensures freshness before making upstream requests. Retry logic at the client level became unnecessary.
Frontend Complexity → Dramatically Reduced
We deleted large sections of defensive authentication code:
- No more timers
- No more event listeners
- No more retry abstractions in our data clients
The UI became responsible only for rendering and redirecting when middleware denied access.
Debugging → Much Easier
Instead of tracing refresh logic across React hooks, Apollo links, and REST interceptors, we could focus on a single auth boundary: the NextAuth JWT callback.
Trade-Offs and Considerations
This approach works best when:
- ✅ You're using Next.js App Router as intended
- ✅ Sessions are stored in HttpOnly cookies
- ✅ Access tokens are not unnecessarily exposed to the client
- ✅ You're comfortable with server-centric data fetching
What about Client Components that need to make API calls?
We route all client-side API calls through Next.js API Routes. This ensures the server always handles token management before proxying to your backend.
Here's the pattern:
// app/api/dashboard/route.ts
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) {
return new Response("Unauthorized", { status: 401 })
}
// Token is automatically refreshed if needed via JWT callback
const response = await fetch("https://api.example.com/dashboard", {
headers: {
Authorization: `Bearer ${session.accessToken}`
}
})
const data = await response.json()
return Response.json(data)
}// components/DashboardClient.tsx
"use client"
export function DashboardClient() {
const [data, setData] = useState(null)
useEffect(() => {
// Client calls our Next.js API route, not the external API directly
fetch('/api/dashboard')
.then(res => res.json())
.then(setData)
}, [])
return <div>{/* render data */}</div>
}The Bigger Lesson
This wasn't just about token refresh. It was about responsibility and boundaries.
We had allowed infrastructure concerns to leak into the UI layer. The result was complexity, fragility, and subtle production bugs that only appeared under specific conditions.
By moving token lifecycle management to the server, we restored separation of concerns:
- Server owns: Authentication state, token lifecycle, session validation
- Client owns: Rendering, user interaction, presentation logic
- APIs trust: The server's authority
Sometimes the senior move is not adding smarter retry logic or more defensive abstractions.
Sometimes it's deleting code and moving responsibility to the correct boundary.
Questions to Ask Yourself
If your frontend contains:
- ⚠️ Token expiry timers
- ⚠️ Refresh event listeners (focus, visibility)
- ⚠️ Cross-tab synchronization logic
- ⚠️ Defensive 401 wrappers and retry logic
Ask yourself: Do these responsibilities truly belong in the UI?
In a modern Next.js application with App Router and Server Components, they probably don't.
Key Takeaways
- Authentication is infrastructure, not presentation - Keep it server-side
- Browsers are unreliable execution environments - Don't trust timers or events for critical operations
- Centralize token management - One source of truth prevents race conditions
- Leverage the framework - Next.js App Router is designed for server-side session handling
- Delete code, not add abstractions - Sometimes less is more
Have you dealt with similar authentication challenges? What patterns worked (or didn't work) for you? I'd love to hear about your experiences in the comments.