Repository navigation
Next.js 16: What Is the Best Approach for JWT Refresh Token Rotation? #99616
Replies: 9 comments
|
Short version: make Why
|
| Task | Where |
|---|---|
| Detect expired/near-expiry access token | proxy.ts (decode the exp claim; no signature check needed, the backend verifies) |
| Call the backend refresh endpoint | proxy.ts |
| Set the new HTTP-only cookies | proxy.ts, on the response |
| Make the new token visible to the current request | proxy.ts, by rewriting the forwarded request cookies |
| Retry | Not needed: refresh happens before the request reaches your code |
| Server Components / Actions / Route Handlers | Only read the access token from cookies() and call the API |
Refresh proactively (for example when exp is less than 60s away) instead of waiting for a 401. Then there's nothing to retry in the normal case.
Sketch
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
const ACCESS = 'access_token'
const REFRESH = 'refresh_token'
function expiresSoon(jwt: string | undefined, skewSeconds = 60) {
if (!jwt) return true
try {
const payload = JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
return payload.exp * 1000 - Date.now() < skewSeconds * 1000
} catch {
return true
}
}
export async function proxy(request: NextRequest) {
const access = request.cookies.get(ACCESS)?.value
const refresh = request.cookies.get(REFRESH)?.value
if (!refresh || !expiresSoon(access)) return NextResponse.next()
const res = await fetch(`${process.env.API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refreshToken: refresh }),
})
if (!res.ok) {
// Refresh token invalid or reused: clear session and send to login
const out = NextResponse.redirect(new URL('/login', request.url))
out.cookies.delete(ACCESS)
out.cookies.delete(REFRESH)
return out
}
const { accessToken, refreshToken } = await res.json()
// 1. Forward the new tokens to THIS request, so Server Components /
// Actions / Route Handlers see A2 via cookies() right away
request.cookies.set(ACCESS, accessToken)
request.cookies.set(REFRESH, refreshToken)
const out = NextResponse.next({ request: { headers: request.headers } })
// 2. Persist them in the browser
const base = { httpOnly: true, secure: true, sameSite: 'lax' as const, path: '/' }
out.cookies.set(ACCESS, accessToken, base)
out.cookies.set(REFRESH, refreshToken, base)
return out
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|login).*)'],
}Step 1 is the part people usually miss: setting cookies on the response alone only affects the next request. Without rewriting the forwarded request, the Server Components rendering right now still see the expired A1.
Concurrent requests (the hard part)
With strict rotation, two parallel requests can both send R1. The first gets R2, the second hits a "reused" R1 and, if your backend does reuse detection, kills the whole session. Common in practice: client navigation fires several RSC requests at once, and prefetching makes it worse.
You can't fully fix this in Next.js alone, because requests may hit different server instances. Layered approach:
- Backend grace window (the real fix): accept the previous refresh token for a few seconds after rotation and return the same A2/R2 it already issued. Only treat R1 as reuse after that window. Auth0, Okta and most IdPs with rotation do exactly this ("reuse interval" / "leeway").
- Dedupe per instance in
proxy.ts: keep aMap<refreshToken, Promise<tokens>>so concurrent requests on the same server share one refresh call. Delete the entry once the promise settles. This cuts most collisions but doesn't replace step 1. - Refresh early (the 60s skew above), so most requests never race at the expiry edge.
Client Components
Don't call your backend directly with the token from the browser (the cookie is HTTP-only anyway). Go through a Server Action or a Route Handler; both pass through proxy.ts, so they get the refresh for free.
Docs: proxy.ts cookies, cookies() behavior, middleware renamed to proxy in v16.
|
In Next.js App Router with rotating refresh tokens and HTTP-only cookies, the recommended architecture handles the following critical considerations: 1. Where to Refresh
2. Preventing Multi-Tab Concurrency Race ConditionsWhen using rotating refresh tokens (R1 -> R2):
3. Server Component PatternIn Server Components, Hope this helps! If this resolves your question, please feel free to mark this as the accepted answer. |
|
In Next.js 16 App Router with an external API and HTTP-only cookies, the recommended approach separates concerns to avoid race conditions and invalid cookie writes:
// app/api/auth/refresh/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST() {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refreshToken')?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
const res = await fetch('[https://api.yourbackend.com/auth/refresh](https://api.yourbackend.com/auth/refresh)', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
cookieStore.delete('accessToken');
cookieStore.delete('refreshToken');
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
const { accessToken, refreshToken: newRefreshToken } = await res.json();
cookieStore.set('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'lax' });
cookieStore.set('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'lax' });
return NextResponse.json({ success: true, accessToken });
}
Handling Concurrent Requests:
Backend Grace Window: Ensure your backend API allows a 10-30 second grace window for rotated tokens so parallel RSC/page requests don't instantly fail
Middleware Scope: Use Middleware solely to inspect tokens and redirect unauthenticated users to /login, leaving actual rotation logic to API routes or Server Actions |
|
For this setup, I would use a server-side session/BFF, with a stable opaque session ID in the browser cookie and the access/refresh tokens in a shared server-side store. That is an architectural recommendation, rather than a Next.js requirement, but it addresses the Server Component and concurrency problems together. The distinction is rotating the backend token does not have to mean rotating the browser cookie. A Server Component can read the session ID, call a server-only API service, and have that service refresh the backend tokens in the store. It does not need to call I would split the responsibilities like this:
For parallel requests, use coordination across instances, keyed by session ID. A process-local promise map alone is insufficient. The sequence should be: acquire coordination → re-read the session record → refresh only if it is still needed → atomically persist the new token pair/version → release. Waiting requests then use the stored result. Account for lock expiry and a worker failing after NestJS rotates but before the new pair is saved; a lock by itself cannot make that cross-service operation atomic. Any retry/grace policy must be explicitly supported by the backend, bounded, and preserve replay detection. Retry an API request at most once after a confirmed expiry; only replay mutations if the backend contract guarantees the first attempt did not execute, or you have an idempotency key. Treat timeouts, 429 and 5xx differently from confirmed invalid/revoked refresh tokens—an API outage should not delete every user's session. The cost is a shared session store and an extra lookup. Protect tokens at rest, expire/revoke sessions, use a Secure/HttpOnly cookie in production, and protect cookie-authenticated mutations against CSRF. If you must keep the rotating tokens in browser cookies instead, Route Handlers/Actions can write them, but you still need to design how a page render reaches that browser-visible refresh response and how parallel rotations are coordinated. References: Next.js authentication guide, cookie write restrictions, and BFF guide. A useful verification is to expire the access token, trigger two navigations/tabs simultaneously against two app instances, and confirm one effective rotation, both requests succeeding, and no token appearing in client JSON or logs. |
|
For Next.js 16 App Router + a separate backend, a clean approach is:
The key idea: the backend owns token rotation and revocation; Next.js mainly handles the cookie and request flow. |
|
Handling rotating refresh tokens with HTTP-only cookies in Next.js 16 App Router has one major architectural constraint you must design around:
Because of this, the industry-standard production architecture is the Middleware Pre-emptive Refresh + Centralized Server Fetch Wrapper (BFF) pattern. Here is the exact breakdown and recommended production architecture. 1. Responsibility Matrix: Who Does What?
2. How the Complete Request Lifecycle WorksA. Page Loads & Navigations (Server Components)
B. Server Actions (Form submissions / Mutations)
C. Client-Side Components
3. How to Solve the Concurrent Request Race Condition (R1 Replay Attack)When 3 Server Components or parallel fetches run simultaneously, they may all attempt to exchange In production, this is solved with a two-part strategy:
4. Production Code ImplementationStep 1: Middleware (
|
|
For production, I would avoid making every matched The most robust design is:
This avoids the Server Component cookie-write problem entirely. Server Components can read the stable session cookie, while refreshed token pairs are updated in the server-side store rather than requiring a new browser cookie after every rotation. For concurrent requests, use distributed coordination keyed by session ID:
A process-local promise map helps on one instance but is insufficient when requests reach different instances. The authentication backend should also support a bounded idempotency or grace window for concurrent uses of the previous refresh token. If tokens must remain in browser cookies, refresh through a Route Handler or Server Action that returns directly to the browser and can emit Additional safeguards:
This follows Next.js's documented model: Server Components can read cookies but cannot set them, while Server Functions and Route Handlers can write cookies. Proxy is best suited to optimistic checks rather than being the only security boundary. References: Authentication guide, |
|
One concrete concurrency issue in the examples above is worth fixing before choosing the refresh layer: a single module-level If Alice starts a refresh and Bob arrives while it is pending, Bob awaits Alice's promise. If the caller then writes that returned token pair to Bob's response cookies, it crosses the session boundary. This is different from two requests by the same user racing over R1. I checked that behavior with a small Node.js test using synthetic tokens: the global-promise version returned Alice's result to both callers. A coordinator keyed by session kept Alice/Bob separate, deduplicated two calls for the same session, and allowed a retry after rejection. For illustration, this is process-local deduplication only, not a complete rotation protocol: function singleFlight(refresh) {
const inFlight = new Map();
return (sessionId, refreshToken) => {
const existing = inFlight.get(sessionId);
if (existing) return existing;
const pending = Promise.resolve()
.then(() => refresh(refreshToken))
.finally(() => {
if (inFlight.get(sessionId) === pending) inFlight.delete(sessionId);
});
inFlight.set(sessionId, pending);
return pending;
};
}Derive the session key from authenticated server-side state. Across instances, coordination must live in the auth backend/shared session service; after acquiring it, re-read the current token version before rotating. This helper does not resolve stale R1 requests arriving after the promise is removed, worker crashes, or out-of-order browser cookie writes. Those need an explicit backend rotation/retry contract. For the Next.js split, the server-side session/BFF approach already described above fits this well: one stable browser session ID, with token pairs updated server-side. If keeping tokens in browser cookies, refresh through a browser-visible Route Handler/Server Action (or carefully scoped Proxy); an internal RSC fetch does not deliver its Next.js documents both the cookie-write restriction during Server Component rendering and that Proxy should not rely on shared globals. I would test two different users concurrently as well as two requests for one user before deploying either design. |
|
When using a separate backend API with rotating refresh tokens and Here is the cleanest pattern to handle this in Next.js 16: 1. Centralize Token Refresh in
|
Uh oh!
There was an error while loading. Please reload this page.
Summary
I'm looking for advice on the recommended approach for JWT refresh-token rotation in Next.js 16.
My setup is:
The backend handles refresh-token rotation:
R1 → refresh request → A2 + R2
R2 then becomes the new valid refresh token.
My main question is:
What is the best approach in Next.js for handling the refresh flow and setting the new HTTP-only cookie?
Should this be handled with:
More specifically, which approach should be responsible for:
And how should this work when authenticated requests come from Server Components, Server Actions, and client-side navigation?
I'm also interested in how the recommended approach handles concurrent requests with rotating refresh tokens.
What is the recommended production architecture for this setup in Next.js 16?
Additional information
No response
Example
No response
All reactions