- 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.
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/loginreturns{ access_token, refresh_token }POST /auth/refreshtakes 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.
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. Trydocument.cookiein the demo's console: neither token appears.secure— only sent over HTTPS (in production).sameSite: "lax"— not sent onPOSTrequests 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:
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,
getSessionstill 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.