Hi everyone,
I've spent many days trying to implement a proper token refresh mechanism in Next.js 14 for an application that heavily uses Server Components fetching data during the page render.
Despite trying multiple strategies, I keep facing serious race conditions that invalidate the whole authentication flow and result in users being redirected to login even when their session should be extendable.
Problem Summary
- I have protected routes where access tokens might expire.
- My plan was to detect if the token is about to expire in middleware, and if so, redirect the user to a custom
/api/refreshendpoint. - The refresh endpoint would then request a new access token + refresh token from the backend, set new cookies, and redirect the user back.
- However, because middleware runs in parallel for every server component that initiates a request, if the token is about to expire, multiple parallel refresh requests happen at the same time.
- This leads to only one refresh succeeding (the first one), while the others are based on old cookies that are already invalidated by the first refresh.
- Result: most of the components get 401s and the user is forcefully redirected to login.
Detailed Flow
- Middleware runs.
- Middleware sees that the access token is about to expire.
- Middleware redirects to
/api/refresh. - Multiple server components trigger the middleware in parallel ➔ multiple
/api/refreshcalls at once. - First refresh call succeeds, rotates cookies.
- All other refresh calls use old cookies ➔ fail ➔ force logout.
Question
Is there any Next.js 14+ recommended strategy to handle token refresh?
- How can we make sure that only one refresh happens and the others wait or reuse the new token?
- Or should token refresh not be handled in middleware at all and if so, where would be the correct place in a server-components-heavy app?
Relevant Code
Middleware (middleware.ts)
import { jwtDecode } from "jwt-decode";import createIntlMiddleware from "next-intl/middleware";import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
const TOKEN_COOKIE = "token";
export default async function middleware(request: NextRequest, event: NextFetchEvent) { const requestHeaders = new Headers(request.headers);
const token = request.cookies.get(TOKEN_COOKIE)?.value;
if (token) { try { const decoded: { exp: number } = jwtDecode(token); const isExpiringSoon = decoded.exp - Math.floor(Date.now() / 1000) < 60;
if (isExpiringSoon) { const redirectUrl = new URL("/api/auth/refresh", request.url); redirectUrl.searchParams.set("returnTo", request.nextUrl.pathname);
return NextResponse.redirect(redirectUrl); } } catch (e) { console.error("Error decoding token", e); } }
const handleI18nRouting = createIntlMiddleware({ locales: ["en", "dk"], defaultLocale: "dk", localePrefix: "as-needed", });
const response = handleI18nRouting(request);
return response;}
export const config = { matcher: ["/((?!api|_next|.*\\..*).*)"],};Refresh API Route (/api/auth/refresh/route.ts)
import { cookies } from "next/headers";import { NextRequest, NextResponse } from "next/server";
const ACCESS_TOKEN_COOKIE = "token";const REFRESH_TOKEN_COOKIE = "refresh_token";
export async function GET(req: NextRequest) { const cookieStore = cookies(); const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE); const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE);
const returnTo = req.nextUrl.searchParams.get("returnTo") || "/";
if (!refreshToken || !accessToken) { const res = NextResponse.redirect(new URL("/login", req.url)); res.cookies.delete(ACCESS_TOKEN_COOKIE); res.cookies.delete(REFRESH_TOKEN_COOKIE); return res; }
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.NEXT_PUBLIC_API_AUTH_KEY}`, Userauthorization: `Bearer ${accessToken.value}`, Cookie: `${REFRESH_TOKEN_COOKIE}=${refreshToken.value}`, }, cache: "no-store", });
if (!backendResponse.ok) { const res = NextResponse.redirect(new URL("/login", req.url)); res.cookies.delete(ACCESS_TOKEN_COOKIE); res.cookies.delete(REFRESH_TOKEN_COOKIE); return res; }
const body = await backendResponse.json(); const res = NextResponse.redirect(new URL(returnTo, req.url));
res.cookies.set(ACCESS_TOKEN_COOKIE, body.token, { secure: true, httpOnly: true, sameSite: "strict", path: "/", });
const setCookieHeader = backendResponse.headers.get("set-cookie"); const newRefresh = extractRefreshCookie(setCookieHeader);
if (newRefresh) { res.cookies.set(REFRESH_TOKEN_COOKIE, newRefresh.value, { httpOnly: newRefresh.httpOnly, secure: newRefresh.secure, sameSite: newRefresh.sameSite, path: newRefresh.path, domain: newRefresh.domain, maxAge: newRefresh.maxAge, }); }
return res;}
function extractRefreshCookie(setCookie: string | null) { if (!setCookie) return undefined;
const parts = setCookie.split(";").map((p) => p.trim()); const [nameValue, ...attributes] = parts;
const [name, value] = nameValue.split("=");
if (name !== REFRESH_TOKEN_COOKIE) return undefined;
const cookieOptions: any = { value };
for (const attr of attributes) { const [key, val] = attr.split("=");
switch (key.toLowerCase()) { case "path": cookieOptions.path = val; break; case "domain": cookieOptions.domain = val; break; case "max-age": cookieOptions.maxAge = Number(val); break; case "httponly": cookieOptions.httpOnly = true; break; case "secure": cookieOptions.secure = true; break; case "samesite": cookieOptions.sameSite = val.toLowerCase(); break; } }
return cookieOptions;}Project Setup
{ "dependencies": { "next": "14.0.3", "react": "^18", "react-dom": "^18", "next-intl": "^3.2.0", "axios": "^1.6.2", "jwt-decode": "^4.0.0", "cookie": "^1.0.2" }, "devDependencies": { "typescript": "^5", "tailwindcss": "^3.3.0", "eslint": "^8", "eslint-config-next": "14.0.3" }}
Any help, ideas, or pointers would be immensely appreciated! 🙏 Thank you so much in advance!