February 4, 2026
Kazi Ehsan Aziz
Our backend authentication server rotates refresh tokens. Presenting one returns a new access
token and a new refresh token, and invalidates the refresh token you just
presented. Present that old one a second time and you get a 401 — as
far as the backend is concerned, it no longer exists.
The Next.js app in front of it keeps both tokens in HTTP-only cookies and
refreshes them in middleware.ts. The session cookie is deliberately set
to expire a minute before the JWT inside it does, so "no session cookie"
simply means "time to refresh", and the middleware does that before
handing the request on.
Server Actions pass through the middleware too. An action is a POST like any other request, and refreshing it in place is exactly what you want: the user clicked something, their access token had just expired, and the action gets to continue on a fresh token instead of failing.
The race shows up when the app fires two Server Actions at the same
time — two components mounting in the same commit, each loading its own
data. Two POSTs, two middleware invocations. If they land in the window
where the session cookie has expired but the refresh cookie has not, both
read the same refresh token and both spend it. One wins. The other comes
back 401 for a refresh token that no longer exists, writes no cookies,
and the Server Action riding on it finds no session and redirects the
user to /login. Someone who did nothing but open a page gets signed
out, unreproducibly, because the window moves with every login.
Like most engineering problems, this one can be solved in more than one place. The backend could keep a spent refresh token usable for a few extra seconds and return the pair (access and new refresh) it just minted instead of minting another, which makes a duplicate refresh idempotent — probably the better fix when you own both sides. But when the backend cannot be touched, the frontend can solve it alone: make the second caller wait for the first caller's refresh instead of starting its own. That is what this article explores.
Our middleware has one job. It does not check whether a JWT is valid, it
does not decide who may see what, and it does not redirect anyone. Those
decisions belong to individual page.tsx handlers and to Server Actions,
each of which runs a session check at the top and redirects from there.
So the middleware stays this small:
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { acquireRefreshLock } from "@/lib/refresh-dedupe";
// This middleware does not check validity of JWTs, that's done
// by individual components. The middleware only refreshes tokens.
export async function middleware() {
// 1. Get the session from the cookie
const accessCookie = (await cookies()).get("wwm_session")?.value;
// cookies are always expired 1 minute before JWT expires
const isAuthenticated = !!accessCookie;
// 2. Refresh if refresh token is present
if (!isAuthenticated) {
const refreshCookie = (await cookies()).get("wwm_refresh_token")?.value;
if (refreshCookie) {
await acquireRefreshLock(refreshCookie);
}
}
return NextResponse.next();
}
The presence of the cookie is the authentication check — no decryption, no expiry comparison. That works because of the 60-second head start: a cookie that arrives carries a token with at least a minute of life left in it, so "cookie exists" and "token is usable" are the same statement.
Everything else is in acquireRefreshLock.
import "server-only";
import { refreshToken } from "@/lib/backend/auth";
import { createSession } from "@/lib/session";
// In-memory Map to store ongoing refresh operations
// Key: refresh token value, Value: Promise that resolves when refresh completes
const refreshLocks = new Map<string, Promise<void>>();
/**
* Deduplicates a token refresh across concurrent requests.
* If a refresh is already in progress for the given refresh token,
* returns the existing promise so concurrent requests wait for the same refresh.
* Otherwise, creates a new refresh operation.
*/
export async function acquireRefreshLock(
refreshTokenValue: string,
): Promise<void> {
// Check if a refresh is already in progress for this token
const existingLock = refreshLocks.get(refreshTokenValue);
if (existingLock) {
console.log("refresh-dedupe: waiting for existing refresh to complete");
return existingLock;
}
// Create a new refresh promise
const refreshPromise = (async () => {
try {
const result = await refreshToken(refreshTokenValue);
if (result.success && result.data) {
await createSession(
result.data.tokens.access.token,
result.data.tokens.refresh.token,
);
}
return;
} catch (error) {
console.error("refresh-dedupe failed:", error);
} finally {
// Clean up the lock after a short delay to allow concurrent
// requests to read the result
setTimeout(() => {
refreshLocks.delete(refreshTokenValue);
}, 100);
}
})();
// Store the promise in the map
refreshLocks.set(refreshTokenValue, refreshPromise);
return refreshPromise;
}
The refreshLocks map stores a promise, and a late arrival is handed the
same promise the first caller is already awaiting. When it settles, every
waiter resumes at once. There is no queue, no acquire/release pair to get
wrong, and no way to deadlock, because the only thing anyone ever does
with the lock is await it.
The other half of why it is this simple: the async IIFE is invoked before
refreshLocks.set, but JavaScript runs it synchronously up to its first
await. Between the get at the top of the function and the set at
the bottom, no other request can interleave. Single-threaded execution
gives you the atomicity for free, which is the whole reason a pattern
this small is safe at all.
Keying by user id would seem more natural, but the token value is the better key on two counts.
acquireRefreshLock returns Promise<void>. It could have resolved with
the new tokens, and deliberately does not: the refresh's real output is
the cookies createSession wrote.
Think about a third request that read its cookies while the refresh was
still in flight and reaches acquireRefreshLock a millisecond after it
finished. Its refreshTokenValue is the old, now-spent token — that is
simply what its request carried. With an immediate delete it finds no
lock, starts a fresh refresh with a dead token, and lands us back in the
401 we removed. The delay keeps the spent token's entry around long
enough to absorb those stragglers: they join a promise that has already
resolved, resume immediately, and read the cookies the winner set.
refreshLocks is a module-level Map, so it lives in the memory of
whichever process is running the middleware. Deduplication therefore only
holds for requests that land on the same instance. That is fine for a
single instance and not fine on a platform that runs middleware in many
isolated ones. For multiple instances of your frontend running, the map
has to live in an external storage like Redis.