Refresh token strategy in middleware for server components

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

  1. Middleware runs.
  2. Middleware sees that the access token is about to expire.
  3. Middleware redirects to /api/refresh.
  4. Multiple server components trigger the middleware in parallel ➔ multiple /api/refresh calls at once.
  5. First refresh call succeeds, rotates cookies.
  6. 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!

Hi @arsicdejan, welcome to the Vercel Community!

Thanks for preparing a detailed write up for this problem.

I believe Middlewares are meant to handle preliminary checks on auth tokens and sessions but we should always have solid auth checks in the main data access layer of the application.

My recommendation will be to:

  • Use a centralized/shared auth service that maintains the auth information and deals with managing tokens (including refreshing them). So, even when the auth information is requested → all services await the same source for the new access token, instead of initiating new requests. Hence, avoiding race conditions.
  • Implement auth checks in the data access layer. This means doing the checks before the server components/actions initiate data requests to the database or external services. And because of the shared auth service, even in parallel calls, you will not run into a race condition.
  • Handle the refresh flow in the backend service calls instead of redirecting the requests for the client. Say the data access layer calls the auth service for request validation and that ends up requesting a token refresh: this all can be handled by the backend code in the auth service without a redirect to be initiated. I think redirect to /login is a good strategy when the token as expired already because there the user needs to take actions.

The implementation details for this can be very specific to your project. I hope the suggestion is helpful. I’m curious to see what other community members bring in.

1 Like