mukles.
All posts
  • Auth
  • Next.js
  • Frontend

Refresh Tokens in Next.js, Part 2: Managing Tokens on the Frontend

How a Next.js app keeps tokens in HttpOnly cookies, refreshes them silently in the browser, and handles expiry on server-rendered pages and Server Actions.

Md Mukles Ali9 min read

This is part 2 of a three-part series. Part 1 explains the problem. This part covers the Next.js side. Part 3 covers the backend.

In part 1 we settled on the idea: a short-lived access token and a long-lived refresh token, both in HttpOnly cookies that JavaScript can't read. This post is only about the Next.js app: where the tokens live, and how they get refreshed without the user noticing.

We'll treat the backend as a black box with three endpoints:

  • POST /auth/login returns { access_token, refresh_token }
  • POST /auth/refresh takes a refresh token and returns a new pair
  • Any other endpoint expects Authorization: Bearer <access_token>

The code is from the demo repo (apps/web), and you can try it live.

Step 1: Never let the browser talk to the API directly

The API usually lives on a different domain, say api.example.com while the app is on example.vercel.app. If the API sets cookies itself, the browser treats them as third-party cookies. Safari blocks those, other browsers are following, and making them work at all needs SameSite=None and careful CORS setup.

The fix: the browser only ever talks to your Next.js app. A catch-all Route Handler at /api/backend/[...path] forwards every request to the API. Because it runs on your own domain, its cookies are ordinary first-party cookies.

Browsercan't read cookiesNext.js/api/backend/*NestJS APIno cookies at allrequest →← responsecookieBearerJSON tokensSet-Cookie
The browser only talks to Next.js. The route handler turns cookies into a Bearer header, and turns tokens in the API's JSON back into HttpOnly cookies.

Here's the part that attaches tokens on the way out. The access token goes in a header; for refresh and logout, the refresh token goes in the body:

// app/api/backend/[...path]/route.ts
if (REFRESH_PATHS.has(route)) {
  headers.set("content-type", "application/json");
  body = JSON.stringify({
    refresh_token: request.cookies.get(REFRESH_COOKIE)?.value,
  });
} else {
  const accessToken = request.cookies.get(ACCESS_COOKIE)?.value;
  if (accessToken) headers.set("authorization", `Bearer ${accessToken}`);
}

And on the way back, when the API returns tokens, the handler moves them into cookies and strips them from the JSON, so they never reach browser JavaScript:

if (TOKEN_ISSUING_PATHS.has(route) || route === "auth/logout") {
  const payload: unknown = await upstream.json().catch(() => null);
  const tokens = upstream.ok ? readAuthTokens(payload) : null;
  const response = NextResponse.json(withoutTokens(payload) ?? {}, {
    status: upstream.status,
  });

  if (tokens) setAuthCookies(response, tokens);
  else if (route === "auth/logout" || upstream.status === 401) {
    clearAuthCookies(response);
  }
  return response;
}

Step 2: Set the cookies correctly

Every flag on these cookies has a job:

// lib/auth/constants.ts
export const COOKIE_OPTS = {
  httpOnly: true,
  sameSite: "lax" as const,
  secure: process.env.NODE_ENV === "production",
  path: "/",
};

// lib/auth/server/session.ts
export function setAuthCookies(res: NextResponse, tokens: AuthTokens): void {
  res.cookies.set(ACCESS_COOKIE, tokens.accessToken, {
    ...COOKIE_OPTS,
    ...(tokens.expiresIn ? { maxAge: tokens.expiresIn } : {}),
  });
  res.cookies.set(REFRESH_COOKIE, tokens.refreshToken, {
    ...COOKIE_OPTS,
    maxAge: REFRESH_COOKIE_MAX_AGE,
  });
}
  • httpOnly — JavaScript can't read the cookie. Try document.cookie in the demo's console: neither token appears.
  • secure — only sent over HTTPS (in production).
  • sameSite: "lax" — not sent on POST requests from other sites, which blocks most CSRF attacks.

Step 3: Refresh silently in the browser

The access token lasts 60 seconds, so API calls will regularly come back 401. The client should refresh and retry, and the user should never know.

There's a trap. A dashboard often sends several requests at once. When the token expires, they all fail together, and a naive client starts one refresh per request:

Each request refreshesClientAPIOne shared refreshClientAPI4 refreshes · 3 look like reuse1 refresh · others await it
Four requests hit a 401 at once. Without deduplication each starts its own refresh, and with rotation three of them present an already-used token. With one shared promise there's a single refresh.

That's wasteful, and since each refresh token works only once, every refresh after the first sends a token that's already used — which the server treats as suspicious.

The fix is to share one refresh between everyone who needs it:

// lib/backend-client.ts
let refreshInFlight: Promise<Response> | undefined;

function refreshSession(): Promise<Response> {
  refreshInFlight ??= backendFetch("/auth/refresh", { method: "POST" }).finally(
    () => {
      refreshInFlight = undefined;
    },
  );
  return refreshInFlight;
}

export async function backendFetchWithAutoRefresh(
  path: string,
  init?: RequestInit,
): Promise<{ res: Response; didRefresh: boolean }> {
  let res = await backendFetch(path, init);
  if (res.status !== 401) return { res, didRefresh: false };

  const refreshResponse = await refreshSession();
  if (!refreshResponse.ok) return { res: refreshResponse, didRefresh: false };

  res = await backendFetch(path, init);
  return { res, didRefresh: true };
}

The first 401 starts the refresh and stores the promise. Every other failing request finds that promise and waits on it. One refresh call, and every request retries once.

Notice the client never touches a token. It calls /auth/refresh on its own Route Handler, which reads and writes the cookies.

Step 4: Refresh before server-rendered pages

The browser retry doesn't help pages rendered on the server. A Server Component renders once, and more importantly, it can't set cookies. Next.js only allows that in Route Handlers, Server Actions and the Proxy.

What happens if you refresh inside a Server Component anyway? This Better Auth issue shows it well. By the time the component runs, the response headers are already on their way to the browser, so the new cookie is silently lost. The browser keeps the old tokens, the next request refreshes again, and so on — people in that thread saw the same expiry time on every single request.

So the refresh has to happen before rendering starts. In Next.js 16 that's the job of the Proxy (proxy.ts, formerly middleware):

// proxy.ts
export async function proxy(request: NextRequest) {
  const claims = decodeAccessToken(request.cookies.get(ACCESS_COOKIE)?.value);
  const now = Math.floor(Date.now() / 1000);
  const isExpired = claims?.exp
    ? claims.exp - ACCESS_EXPIRY_SKEW_SECONDS <= now
    : true;

  if (claims && !isExpired) return NextResponse.next();

  const refreshToken = request.cookies.get(REFRESH_COOKIE)?.value;
  if (isExpired && refreshToken) {
    const tokens = await rotateTokens(refreshToken);
    if (tokens) {
      // Make the rotated tokens visible to this request's Server Components.
      request.cookies.set(ACCESS_COOKIE, tokens.accessToken);
      request.cookies.set(REFRESH_COOKIE, tokens.refreshToken);
      const response = NextResponse.next({
        request: { headers: request.headers },
      });
      // Persist the same rotated pair in the browser.
      setAuthCookies(response, tokens);
      return response;
    }
  }

  const res = NextResponse.redirect(new URL("/login", request.url));
  clearAuthCookies(res);
  return res;
}

The key line is that the new tokens are written twice:

  • On the response, so the browser saves them for next time.
  • On the request, so the Server Components rendering right now see the new access token. The same Better Auth issue hit this too: right after setting the new cookie, getSession still returned the old tokens, because the current request hadn't changed.

ACCESS_EXPIRY_SKEW_SECONDS (10 seconds) refreshes slightly early, so a token can't expire halfway through rendering.

Step 5: Server Actions get it for free

The demo's profile form saves through a Server Action. It reads the access token from the cookie and sends it to the API:

// lib/backend-server.ts
export async function createServerBackendTransport() {
  await connection();
  if (!API_BASE_URL) throw new Error("API_BASE_URL is not configured");

  const accessToken = (await cookies()).get(ACCESS_COOKIE)?.value;
  return createBackendTransport(API_BASE_URL, {
    cache: "no-store",
    headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {},
  });
}

It doesn't refresh anything itself, and it doesn't need to. A Server Action is a POST to the page's own URL (/dashboard/settings), which the Proxy's matcher covers. So the Proxy has already refreshed an expired token before the action runs. The action only handles the case where refreshing was impossible, and shows "Your session expired. Please sign in again."

Recap

Problem Fix in Next.js
API cookies are third-party Route Handler proxy on your own domain
Scripts could read tokens HttpOnly cookies, tokens stripped from JSON
Many 401s, many refreshes One shared in-flight refresh
Server Components can't set cookies Refresh in the Proxy, before rendering
Current request has old token Write new tokens to request and response
Server Actions need a fresh token Covered by the Proxy automatically

The frontend never handles a token directly — it only ever sees cookies it can't read. In part 3 we'll open the black box and build the backend: how refresh tokens are issued, rotated, and how stolen ones get caught.

Working on something similar?

I'm happy to talk through architecture, AI features or production issues.