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

Refresh Tokens in Next.js, Part 1: The Problem

Why the usual token setups are unsafe, and why the normal way of refreshing tokens breaks in Next.js — who can set cookies, and the race conditions that log users out.

Md Mukles Ali9 min read

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

Making a user log in is easy. Keeping them logged in — safely, for days, without them noticing anything — is where most apps go wrong. This post is only about why that's hard: what goes wrong with the usual setups, and why the normal way of refreshing tokens doesn't work in Next.js. The fixes come in parts 2 and 3.

All three parts are based on a working project you can try:

What a token is for

After login, the server gives the browser a token: a string that proves "this is user 42". The browser sends it with every request, so the user doesn't have to type their password again.

That raises two questions:

  1. Where does the browser keep the token?
  2. How long does the token work?

Most security problems in login systems come from a bad answer to one of these.

Three common setups, and how each breaks

1. One long-lived token in localStorage

The server returns a token valid for a week. The frontend saves it in localStorage and sends it on every request.

It's simple, but:

  • Any JavaScript on the page can read localStorage. One XSS bug, one bad npm package or one malicious browser extension can copy the token and send it to an attacker. The attacker can then act as the user, from their own computer, for the rest of the week.
  • The server can't take it back. If the user's laptop is stolen, there's no way to cancel a token that was already issued.

2. One short-lived token

Make the token expire after 15 minutes. A stolen token is now useless quickly.

But the user is also logged out every 15 minutes. Nobody will use an app like that.

3. A short token plus a long token, both in localStorage

Add a second, long-lived token whose only job is to get a new short one. Users stay logged in.

But the long token is now sitting in localStorage, readable by any script. It's worth more to an attacker than the short one, because it keeps working for days. This setup made the original problem worse.

What we actually need

Looking at those failures, a good design has to do five things:

  1. Scripts can't read the tokens, so an XSS bug can't steal them.
  2. A stolen token stops working quickly.
  3. The server can end a session whenever it needs to.
  4. Theft gets noticed, not just limited.
  5. The user never sees any of this. No surprise logouts, no failed pages.

The idea: two tokens, kept where scripts can't reach

The design used throughout this series has two parts.

Two tokens with different jobs:

Access token Refresh token
Job proves who you are on every request gets a new access token
Lifetime very short (60 seconds in the demo) long (7 days)
Can the server cancel it? it simply expires yes, it's stored in the database

The access token is sent all the time, so it's short-lived: if it's stolen, it's useless within a minute. The refresh token lives longer, but it's used rarely, and the server checks it in the database each time, so the server can cancel it.

Both stored in HttpOnly cookies. The browser sends these cookies automatically, but JavaScript can't read them at all. That's need #1: there's nothing in the page for an XSS script to steal.

One more rule handles need #4. Every refresh token works only once. Each time it's used, the server gives back a new one. If an old, already-used token ever shows up again, someone must have copied it — so the server ends the whole session.

Here's how the two tokens move over time:

Access tokenRefresh tokenJWT · 60sRT-1JWT · 60sRT-2refreshJWT · 60sRT-3refreshJWT · 60sRT-4refreshtime →
The access token expires every minute and is replaced silently. Each refresh also swaps the refresh token, so no refresh token is ever used twice.

Every minute, the access token expires and is replaced. At the same moment, the refresh token is swapped for a new one. The user doesn't notice any of it.

Why the normal way doesn't work in Next.js

The design above is well established. Implementing it in Next.js is where teams get stuck, because the standard refresh pattern rests on an assumption that Next.js doesn't hold.

The standard pattern

In a single-page React app, token refresh is one interceptor:

api.interceptors.response.use(undefined, async (error) => {
  if (error.response?.status !== 401) throw error;

  const { data } = await axios.post("/auth/refresh", {
    refreshToken: localStorage.getItem("refreshToken"),
  });
  localStorage.setItem("accessToken", data.accessToken);
  localStorage.setItem("refreshToken", data.refreshToken);

  return api(error.config); // retry the original request
});

The pattern works because of one assumption: a single runtime owns the session. Every request leaves from the browser, passes through the interceptor, and the interceptor can both read the old token and persist the new one.

Next.js runs your code in several runtimes, with different abilities, often at the same time. That assumption breaks in three places.

Problem 1: Who can set a cookie

A cookie is written with a Set-Cookie response header. Whether a piece of code can write one depends on whether it still controls the response headers when it runs. In the Next.js request lifecycle, that splits cleanly:

Runtime Read the token cookie Write the token cookie Why
Client Component no no HttpOnly is enforced by the browser; document.cookie can't see or create it
Server Component yes no renders while the response is streaming; headers are already sent
Route Handler yes yes owns its response
Server Action yes yes owns its response
Proxy (proxy.ts) yes yes runs before anything else

Two consequences matter.

The browser can no longer refresh. Moving tokens into HttpOnly cookies, which part 1's threat model requires, removes the interceptor's ability to read or persist them. The browser can still trigger a refresh, but a server runtime has to perform it.

Server Components can't persist a refresh. Rendering is a read-only phase for cookies. Next.js streams HTML as components resolve, so by the time a component awaits a fetch, the headers have been flushed. Calling cookies().set() there throws "Cookies can only be modified in a Server Action or Route Handler."

If the refresh runs anyway, for example inside a shared fetch helper with the error swallowed, you get a split-brain session: the backend has issued a new token pair, and the browser still holds the old one. Every following request refreshes again. Teams migrating to Better Auth hit exactly this loop. Under rotation it's worse than a loop: the browser's refresh token is already marked as used, so the next refresh is indistinguishable from a replay attack, and the session is revoked.

There's a second, quieter version of the same problem: read-after-write within one request. Even where you can set the cookie, Set-Cookie changes the response. Anything later in the same request that reads cookies() still sees the incoming request's old value. A refresh in the Proxy therefore has to write the new tokens to the forwarded request as well as to the response.

The rule: refresh only in a runtime that owns the response, and before any code reads the token. For page loads that's the Proxy; for browser-initiated API calls, a Route Handler.

Problem 2: Race conditions

With rotation, refresh is a single-use, non-idempotent operation: a refresh token can be exchanged exactly once. Any time two refreshes start with the same token, only one can succeed. Next.js makes concurrent refreshes routine:

  • Fan-out in one tab. Several Client Components mount and fetch in parallel. The access token has expired, so they all receive 401 and each starts a refresh.
  • Multiple tabs. Each tab has its own JavaScript context and its own interceptor, but they share one cookie jar.
  • Across runtimes. A navigation sends the Proxy to refresh before rendering, while an in-flight browser request sends a Route Handler to refresh. Two server runtimes, same refresh cookie.
  • Across instances. On serverless hosting, those two requests can land on different instances with no shared memory.

Losing the race has two failure modes:

  1. False theft detection. The loser presents a token the winner just consumed. To the backend, that looks exactly like a stolen token being replayed, so it revokes the session.
  2. Cookie clobbering. If both refreshes return different token pairs, both responses send Set-Cookie, and the browser keeps whichever arrives last. If that's the loser's pair, the browser now holds an invalid token.

None of the usual client-side fixes can solve this alone. An interceptor's lock only covers its own tab. An in-memory mutex on the server only covers one instance. The only component that sees every refresh is the backend, so it has to be the source of truth:

  • Consuming a refresh token must be an atomic compare-and-set, so exactly one request wins.
  • A short grace window makes the refresh idempotent: a request that loses the race within a few seconds receives the same successor token the winner got, instead of an error. Because every racer ends up with the same refresh token, the browser's cookie is correct whichever response arrives last.

The client still helps by collapsing its own fan-out into one request (single-flight), which removes most of the races before they reach the server. But correctness lives in the backend.

Problem 3: The API is on another origin

In production, the API usually lives on a different site than the app: api.example.com versus example.vercel.app. Cookies it sets are third-party from the browser's point of view. That forces SameSite=None (giving up CSRF protection), credentialed CORS, and still fails where browsers block third-party cookies, as Safari already does.

The fix is architectural: put a backend-for-frontend in the Next.js app. The browser talks only to its own origin, and the Next.js server talks to the API. Cookies stay first-party, and the API never deals with them.

Requirements for a correct design

Putting it together, a correct Next.js implementation needs to:

  1. Refresh only in runtimes that own the response. Use the Proxy for navigations and a Route Handler for browser API calls, and write the new tokens to both the request and the response.
  2. Collapse concurrent refreshes in the browser into a single in-flight request.
  3. Make refresh race-safe on the backend, with an atomic consume and an idempotent grace window.
  4. Keep cookies first-party through a same-origin proxy.

None of this requires an auth library. It requires knowing which runtime is allowed to do what.

What's next

Working on something similar?

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