Skip to main content
Refresh Token Rotation in NestJS and Next.js: Reuse Detection, Token Families, and the Parallel-Refresh Race — Field Notes

Refresh Token Rotation in NestJS and Next.js: Reuse Detection, Token Families, and the Parallel-Refresh Race — Field Notes

October 26, 2026
Full-Stack Engineering
10 min read

Refresh token rotation means the server issues a new refresh token on every refresh and invalidates the old one. Reuse detection means that when an already-rotated refresh token comes back, the server revokes every token from that login. At Devya we use both in our NestJS APIs behind Next.js App Router frontends: we store only SHA-256 hashes of opaque refresh tokens in Postgres, rotate them with one compare-and-set UPDATE, and allow a 10-second grace window so parallel refreshes from the same browser are not mistaken for theft.

The session design our team started from is a common one: a short-lived JWT access token and a 30-day refresh token, both in httpOnly cookies, and a refresh endpoint that signs a new access token and leaves the refresh token untouched. It works until the refresh token leaks. Whoever holds a copy has a 30-day session, and the server cannot tell the two holders apart. These are our notes on replacing that design with rotation and reuse detection, and on the concurrency bug that shows up as soon as you do.

Key takeaways

Refresh token rotation replaces the refresh token on every use, so a leaked refresh token stays valid only until someone uses it.

Reuse detection treats a second use of an already-rotated refresh token as theft and revokes the whole token family, meaning every refresh token descended from one login.

Refresh tokens should be opaque random strings stored as SHA-256 hashes, not JWTs, because the server has to look each one up to rotate and revoke it anyway.

Rotation must be an atomic compare-and-set, an UPDATE that only matches rows where rotated_at is still NULL and returns the row, or two concurrent requests can both consume the same refresh token.

Parallel refreshes from one browser look exactly like token theft, so the client needs a single-flight refresh and the server needs a short grace window.

What do refresh token rotation and reuse detection protect against?

Refresh token rotation and reuse detection limit how long a stolen refresh token stays useful and make its use detectable. A refresh token is a long-lived credential whose only job is to obtain new short-lived access tokens; the access token is the credential the API checks on every request.

Without rotation, one refresh token works for its whole lifetime, so a copy leaked through a log line, a compromised device or a misconfigured proxy is a silent second session. With rotation, every successful refresh returns a new refresh token and marks the old one as used. If the attacker refreshes first, the legitimate client's next refresh presents a used token; if the client refreshes first, the attacker's attempt does. In both cases the server sees a rotated token come back, cannot tell which caller is legitimate, and revokes the family. Both sessions end, and the real user signs in again.

RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, requires refresh tokens issued to public clients to be either sender-constrained or rotated. A first-party Next.js and NestJS session is not an OAuth flow, but a refresh cookie faces the same threat model.

How should refresh tokens be stored in Postgres?

We store each refresh token as a row keyed by the SHA-256 hash of an opaque random string, grouped by a family_id created at login. The raw token exists only in the cookie, so a database leak exposes hashes that cannot be replayed.

Our refresh_tokens table has one row per token: the family_id shared by every token from one login, the user_id, a unique token_hash column, expires_at for the idle timeout of that token, family_expires_at for the absolute session lifetime, rotated_at set when the token is used, and revoked_at set on reuse or logout. An index on family_id makes revoking a whole family a single cheap UPDATE.

Each raw token is 32 bytes from Node.js crypto.randomBytes(), encoded as base64url. A fast hash such as SHA-256 is enough because the input already has 256 bits of entropy; slow password hashes like bcrypt exist for low-entropy passwords and would also rule out a unique-index lookup.

The two expiry columns do different jobs. We clamp every new token's expires_at to family_expires_at, so rotation keeps an active session alive without ever extending it past the absolute limit. The values we use are 10 minutes for access tokens, 7 days idle and 30 days absolute.

How do we rotate a refresh token atomically in NestJS?

Atomic rotation is a single UPDATE that sets rotated_at only where the token hash matches and rotated_at and revoked_at are still NULL, then returns the row, so exactly one request can consume a given refresh token. A read-then-write version, where the service selects the row, checks it in TypeScript and then updates it, lets two concurrent requests both pass the check and both receive successors.

When the UPDATE matches nothing, our NestJS RefreshTokenService looks the hash up again and lets Postgres compute two booleans: whether the token was ever rotated, and whether it was rotated less than 10 seconds ago in a family that is not revoked. A token inside that grace window gets a successor, a rotated token outside it revokes every row in the family, and an unknown or expired token gets a plain 401. Expired tokens never trigger revocation, because an old cookie on a laptop that slept for a month is not an attack.

Issuing a successor means 32 new random bytes, an INSERT of their hash in the same family, and a 10-minute access token signed with @nestjs/jwt whose sub claim is the user and whose sid claim is the family. In production we run the consuming UPDATE and the successor INSERT in one transaction, so a crash between them cannot leave a session without a valid token.

The refresh controller reads the cookie through cookie-parser and writes both cookies with @Res({ passthrough: true }), which keeps NestJS's normal response handling. We name them with the __Secure- prefix, which makes browsers reject a cookie that is not set with the Secure attribute from an HTTPS origin; the stricter __Host- prefix requires Path=/, so it cannot be combined with a path-scoped refresh cookie.

Reuse detection has one known gap: an access token already issued to an attacker stays valid until its exp claim. Short access tokens bound that gap, and sensitive endpoints can check the sid claim against revoked families.

Why does refresh token rotation log users out at random?

Refresh token rotation logs users out when two requests from the same browser refresh with the same token at the same time: the first request rotates it, and the second presents a token that is already rotated, which is exactly what theft looks like. Every tab shares one cookie jar, so the race happens whenever two tabs wake up together, a page fires parallel API calls after the access token expires, or the Next.js router prefetches several routes at once.

We fix it on both sides. In the browser, every 401 goes through one single-flight refresh, meaning one shared in-flight promise per tab, and the Web Locks API, navigator.locks.request(), serializes refreshes across tabs of the same origin. The Web Locks API is available in current Chrome, Edge, Firefox and Safari.

When the second tab acquires the lock, the cookie jar already holds the new refresh token, so its refresh is an ordinary rotation rather than a reuse. On the server, the grace window accepts a just-rotated token for 10 seconds and issues another successor in the same family instead of revoking it.

The grace window is also a short theft window, so we keep it to seconds. Hosted identity providers such as Auth0 and Okta expose the same trade-off as a configurable leeway or grace period on refresh token rotation.

Should a Next.js App Router app refresh in the proxy or in the browser?

Next.js Server Components cannot set cookies, so a Server Component that finds an expired access token cannot rotate the session itself. Only Route Handlers, Server Actions and the proxy, the file named middleware.ts before Next.js 16, can write cookies, which leaves two workable designs.

Browser-driven refresh: the refresh cookie is scoped to Path=/api/auth/refresh with SameSite=Strict, a fetch wrapper notices expiry after a 401, Server Components with an expired access token render a signed-out state until the client refreshes and calls router.refresh(), concurrency is handled by single-flight plus Web Locks, and the refresh cookie only ever travels to one endpoint.

Proxy-driven refresh: the refresh cookie uses Path=/ with SameSite=Lax, the proxy notices expiry before each page render, Server Components always receive a fresh access token, concurrency is handled by the server grace window, and the refresh cookie travels with every page request.

We use proxy-driven refresh when most data loads in Server Components. The proxy reads the exp claim with decodeJwt() from jose, refreshes only when the access token is missing or expires within 60 seconds, copies the new cookies into the current request with request.cookies.set(), returns NextResponse.next({ request }) so the current render sees them, and passes the Set-Cookie headers from NestJS to the browser unchanged.

decodeJwt() only reads the exp claim to decide when to refresh; NestJS still verifies the signature on every API call. Parallel React Server Component (RSC) prefetches can run the proxy concurrently with the same refresh cookie, which is the case the server grace window exists for. For dashboards that fetch mostly from the browser, we use the browser-driven variant so the long-lived credential is never sent anywhere else.

FAQ

Q: Should refresh tokens be JWTs?

A: No. A refresh token should be an opaque random string stored as a hash, because the server must look it up on every use to rotate and revoke it. A JWT refresh token adds size and a signing key without removing that lookup.

Q: Is refresh token rotation enough, or do we need DPoP?

A: Rotation detects a stolen refresh token after it is used; DPoP (Demonstrating Proof of Possession, RFC 9449) binds tokens to a key pair held by the client, so a copied token cannot be used at all. For a first-party browser app with httpOnly cookies, rotation plus short-lived access tokens is a reasonable baseline, and sender-constrained tokens are the next step for high-risk apps.

Q: What should logout do with refresh tokens?

A: Logout should revoke the entire token family and clear both cookies. Revoking only the current row leaves any sibling token issued during a grace-window refresh valid until it expires.

Q: How long should the reuse grace window be?

A: The grace window should cover a burst of parallel requests from one page load and nothing more. We use 10 seconds; check the gap between duplicate refreshes in your own logs before making it longer.

Q: Why not keep the refresh token in localStorage?

A: Any script on the page can read localStorage, so one XSS bug can exfiltrate a refresh token and replay it from another machine. An httpOnly cookie is unreadable from JavaScript, so an XSS bug can still act inside the open tab but cannot steal the long-lived credential.