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/refresh
endpoint. - 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/refresh
calls 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!