Repository navigation
Replies: 3 comments
|
Yeah, you've identified the race condition correctly - Auth.js acknowledges the same problem in their refresh token rotation docs with no built-in solution yet (Discussion #3940 has been open since 2022). Your module-level // proxy.ts
const inflightRefreshes = new Map<string, Promise<Response>>();
async function refreshTokens(refreshToken: string): Promise<Response> {
const existing = inflightRefreshes.get(refreshToken);
if (existing) return existing; // all parallel requests share this Promise
const promise = fetch(`${DJANGO_URL}/api/login/refresh/auto/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken }),
}).finally(() => {
inflightRefreshes.delete(refreshToken);
});
inflightRefreshes.set(refreshToken, promise);
return promise;
}In your One caveat - the Next.js docs warn against module-level state in proxy.ts because it can run on CDN/Edge where state doesn't persist. Since you're self-hosting with Your |
|
@nathanschram's in-flight Promise deduplication is the correct fix for the immediate race condition. I want to add two things: Why your Redis lock failed Your Django-side lock ( Proactive refresh eliminates the race window entirely Instead of waiting for the access token to expire and then racing to refresh, refresh it before it expires: function shouldRefreshProactively(access: string): boolean {
const payload = JSON.parse(atob(access.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
return expiresIn < 2 * 60 * 1000; // refresh 2 min before expiry
}
async function checkAuth(request: NextRequest) {
const access = request.cookies.get('access')?.value;
const refresh = request.cookies.get('refresh')?.value;
if (access && !isTokenExpired(access) && !shouldRefreshProactively(access)) {
return null; // valid and not near expiry
}
if (access && !isTokenExpired(access)) {
// Token still valid but expiring soon — refresh in background
// Even if this fails, the request proceeds with the current token
refreshTokens(refresh).catch(() => {});
return null;
}
// Token expired — must refresh before proceeding
if (refresh) {
const res = await refreshTokens(refresh); // deduped via Map
// handle response...
}
}This way, the refresh happens during normal navigation while the access token is still valid. By the time it actually expires, you already have a new one. The race window effectively disappears. Combined approach: Use the in-flight Promise deduplication (nathanschram's answer) + proactive refresh. This lets you keep |
|
To summarize the root cause and the best solution path: Root CauseNext.js App Router fires parallel RSC requests on navigation (layout + page + nested segments). When the access token expires mid-navigation, all requests hit Recommended Solution: In-Flight Promise Deduplication + Proactive RefreshCombine both approaches for a robust fix: 1. In-Flight Promise Deduplication (fixes the race)// proxy.ts
const inflightRefreshes = new Map<string, Promise<Response>>();
async function refreshTokens(refreshToken: string): Promise<Response> {
const existing = inflightRefreshes.get(refreshToken);
if (existing) return existing; // all parallel requests share the same Promise
const promise = fetch(`${DJANGO_URL}/api/login/refresh/auto/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken }),
}).finally(() => {
inflightRefreshes.delete(refreshToken);
});
inflightRefreshes.set(refreshToken, promise);
return promise;
}Storing the Promise itself (not a flag) means all 6 parallel RSC requests await the same Django call — only one refresh hits your backend.
2. Proactive Refresh (eliminates the race window entirely)function shouldRefreshProactively(access: string): boolean {
const payload = JSON.parse(atob(access.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
return expiresIn < 2 * 60 * 1000; // refresh 2 min before expiry
}Trigger a background refresh when the token is still valid but expiring soon. By the time users navigate at the expiry boundary, the new token is already in place — no race to trigger. Decision Matrix
The combined approach (in-flight dedup + proactive refresh) lets you keep |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Next.js 16 App Router + Django SimpleJWT: Users Randomly Logged Out Due to Parallel RSC Requests
Additional information
Architecture
npm run start) ↔ Django on localhostproxy.tsacts as auth middleware — reads httpOnly cookies, calls Django refresh endpoint when access token expires, sets new cookiesDjango SimpleJWT config:
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=13),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}
Root Cause
Next.js 16 App Router fires multiple parallel RSC requests on every navigation (layout + page + nested components). When the access token expires at the exact moment of navigation, all of these requests hit
proxy.tssimultaneously.Each sees
accesscookie missing/expired and attempts to refresh using the same refresh token R1.With
ROTATE_REFRESH_TOKENS=True+BLACKLIST_AFTER_ROTATION=True:proxy.tsreceives 401 → callsclearCookiesAndRedirect()→ user logged outThis happens completely transparently to the user — they just see a redirect to the login page.
What We Tried (That Didn't Work)
1. In-memory lock in
proxy.ts:Seemed logical but did not work — unclear if this is because
proxy.tsin Next.js 16 still runs in a context where module-level state isn't reliably shared between parallel requests, or another reason. The 401s continued appearing in Django logs.2. Redis lock in Django:
Parallel requests arrived faster than Redis could process. Still got 401s. Also added unnecessary complexity to Django.
3.
prefetch={false}on Link components:Reduces some prefetch requests but doesn't stop parallel RSC requests during actual navigation.
Current Workaround
Setting
BLACKLIST_AFTER_ROTATION=Falsein Django:This stops the logout because all 6 parallel refresh requests succeed. The token still rotates on each use. The tradeoff: a stolen refresh token won't be immediately invalidated.
This works but it's a security downgrade we'd prefer to avoid.
The Question
How do you properly implement single-flight token refresh in Next.js 16 App Router with a rotating+blacklisting token backend, given that:
proxy.ts/ middleware fires in parallel for multiple RSC requestsMaplock didn't work in our case/api/do-refresh) caused browser permission popupspage.tsx(defeats the purpose of the proxy)Is
BLACKLIST_AFTER_ROTATION=Falsesimply the accepted tradeoff for Next.js App Router + rotating tokens? How does NextAuth/Auth.js handle this exact concurrency problem internally?Any insight appreciated.
All reactions