Skip to content
← All posts
027 min read

Refresh Token Rotation in Next.js 16 App Router — The Complete Implementation

  • jwt
  • authentication
  • security

Stop storing access tokens in localStorage. This guide walks through a production-safe refresh token rotation system in Next.js 16 App Router using httpOnly cookies, middleware, and automatic token refresh — zero exposure to XSS.

Refresh Token Rotation in Next.js 16 App Router — The Complete Implementationn- by Chirag Mehta
Refresh Token Rotation in Next.js 16 App Router — The Complete Implementation

Most authentication tutorials show you the happy path. Login works, JWT is stored, user is authenticated.

What they don’t show you: what happens 15 minutes later when the access token expires. Do you log the user out? Do you silently refresh? What if two API calls fire simultaneously at expiry? What if the refresh token is stolen?

This is the guide for that messy reality. We’re building refresh token rotation in Next.js 16 App Router — the way it should work in production.

Why Refresh Token Rotation?

Why Refresh Token Rotatation?
Why Refresh Token Rotation?

Standard JWT auth gives you two tokens:

  • Access token — short-lived (15 min), sent with every API request
  • Refresh token — long-lived (7–30 days), used only to get new access tokens

Rotation means: every time you use a refresh token, it gets invalidated and replaced with a new one. If someone steals an old refresh token, it’s already dead.

This is the OAuth 2.0 Security Best Practices recommendation, and it’s what your auth should look like in 2026.

Architecture Overview

Browser                    Next.js Server              Auth API
│ │ │
│──── GET /dashboard ───────▶│ │
│ │── validate access token │
│ │ (from httpOnly cookie) │
│ │ │
│ [token expired] │
│ │ │
│ │── POST /auth/refresh ───▶│
│ │◀── new access token ─────│
│ │ new refresh token │
│ │ │
│ │──set new httpOnly cookies│
│◀──── 200 /dashboard ───────│ │

Tokens never touch JavaScript. They live in httpOnly cookies — invisible to XSS attacks.

Step 1: The Token Service

// lib/auth/tokenService.ts
import { SignJWT, jwtVerify } from 'jose';
const ACCESS_SECRET = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET!);
const REFRESH_SECRET = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET!);
export interface TokenPayload {
userId: string;
email: string;
role: 'user' | 'admin';
}
export async function signAccessToken(payload: TokenPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(ACCESS_SECRET);
}
export async function signRefreshToken(payload: TokenPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(REFRESH_SECRET);
}
export async function verifyAccessToken(token: string): Promise<TokenPayload> {
const { payload } = await jwtVerify(token, ACCESS_SECRET);
return payload as unknown as TokenPayload;
}
export async function verifyRefreshToken(token: string): Promise<TokenPayload> {
const { payload } = await jwtVerify(token, REFRESH_SECRET);
return payload as unknown as TokenPayload;
}

Step 2: The Refresh Token Store

The Refresh Token Store
The Refresh Token Store

Refresh tokens must be tracked server-side. If a token is used twice (replay attack), you know it’s been stolen — and you can invalidate the entire family.

// lib/auth/refreshTokenStore.ts
// Using Redis for production (use Upstash for serverless)
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
const TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
export async function storeRefreshToken(
userId: string,
tokenHash: string
): Promise<void> {
// Store token hash (never the raw token)
await redis.setex(`rt:${userId}:${tokenHash}`, TTL_SECONDS, '1');
}
export async function validateAndRotateToken(
userId: string,
tokenHash: string
): Promise<boolean> {
const key = `rt:${userId}:${tokenHash}`;
const exists = await redis.get(key);

if (!exists) {
// Token already used — possible theft detected
// Invalidate ALL refresh tokens for this user
await invalidateAllUserTokens(userId);
return false;
}

// Delete used token (it's now invalid)
await redis.del(key);
return true;
}
export async function invalidateAllUserTokens(userId: string): Promise<void> {
const keys = await redis.keys(`rt:${userId}:*`);
if (keys.length > 0) await redis.del(...keys);
}

Hashing the token before storage ensures a Redis breach doesn’t expose raw tokens.

Step 3: Auth API Routes

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { signAccessToken, signRefreshToken } from '@/lib/auth/tokenService';
import { storeRefreshToken } from '@/lib/auth/refreshTokenStore';
import { createHash } from 'crypto';
export async function POST(req: NextRequest) {
const { email, password } = await req.json();

// Validate credentials (your DB logic here)
const user = await validateUser(email, password);
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}

const payload = { userId: user.id, email: user.email, role: user.role };

const [accessToken, refreshToken] = await Promise.all([
signAccessToken(payload),
signRefreshToken(payload),
]);

// Store hashed refresh token
const tokenHash = createHash('sha256').update(refreshToken).digest('hex');
await storeRefreshToken(user.id, tokenHash);

const response = NextResponse.json({ success: true });

// Set httpOnly cookies — JS can never read these
response.cookies.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60, // 15 minutes
path: '/',
});

response.cookies.set('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60, // 7 days
path: '/api/auth', // Scoped — only sent to refresh endpoint
});

return response;
}

Note the path: '/api/auth' on the refresh token cookie. This means the refresh token is only sent to the refresh endpoint — not to every API route. Minimal exposure.

Step 4: The Rotation Endpoint

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyRefreshToken, signAccessToken, signRefreshToken } from '@/lib/auth/tokenService';
import { validateAndRotateToken, storeRefreshToken } from '@/lib/auth/refreshTokenStore';
import { createHash } from 'crypto';
export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get('refresh_token')?.value;

if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}

try {
// Verify token signature
const payload = await verifyRefreshToken(refreshToken);

// Check token hasn't been used (rotation check)
const tokenHash = createHash('sha256').update(refreshToken).digest('hex');
const isValid = await validateAndRotateToken(payload.userId, tokenHash);

if (!isValid) {
// Potential theft — clear cookies and force re-login
const response = NextResponse.json(
{ error: 'Token reuse detected' },
{ status: 401 }
);
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return response;
}

// Issue new token pair
const newPayload = {
userId: payload.userId,
email: payload.email,
role: payload.role,
};

const [newAccessToken, newRefreshToken] = await Promise.all([
signAccessToken(newPayload),
signRefreshToken(newPayload),
]);

const newHash = createHash('sha256').update(newRefreshToken).digest('hex');
await storeRefreshToken(payload.userId, newHash);

const response = NextResponse.json({ success: true });

response.cookies.set('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60,
path: '/',
});

response.cookies.set('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60,
path: '/api/auth',
});

return response;

} catch {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}

Step 5: Middleware — Automatic Token Refresh

Middleware — Automatic Token Refresh
Middleware — Automatic Token Refresh

This is the magic. The middleware silently refreshes tokens before they expire — users never see a logged-out screen.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/tokenService';
const PUBLIC_PATHS = ['/login', '/register', '/api/auth/login'];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;

// Skip public routes
if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) {
return NextResponse.next();
}

const accessToken = req.cookies.get('access_token')?.value;
const refreshToken = req.cookies.get('refresh_token')?.value;

// No tokens at all — redirect to login
if (!accessToken && !refreshToken) {
return NextResponse.redirect(new URL('/login', req.url));
}

// Try to verify access token
if (accessToken) {
try {
await verifyAccessToken(accessToken);
return NextResponse.next(); // Valid — pass through
} catch {
// Access token expired — try to refresh
}
}

// Access token expired or missing — attempt silent refresh
if (refreshToken) {
try {
const refreshResponse = await fetch(
`${req.nextUrl.origin}/api/auth/refresh`,
{
method: 'POST',
headers: { Cookie: `refresh_token=${refreshToken}` },
}
);

if (refreshResponse.ok) {
// Forward new cookies to the response
const response = NextResponse.next();
refreshResponse.headers.getSetCookie().forEach(cookie => {
response.headers.append('Set-Cookie', cookie);
});
return response;
}
} catch {
// Refresh failed
}
}

// All tokens invalid — redirect to login
return NextResponse.redirect(new URL('/login', req.url));
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Step 6: Handling the Race Condition

What if two API calls fire at the same time when the token is expired? You’d get two simultaneous refresh attempts — both could fail if the first invalidates the token before the second reads it.

// lib/auth/refreshLock.ts
let refreshPromise: Promise<boolean> | null = null;
export async function refreshWithLock(): Promise<boolean> {
// If a refresh is already in progress, wait for it
if (refreshPromise) return refreshPromise;

refreshPromise = fetch('/api/auth/refresh', { method: 'POST' })
.then(res => res.ok)
.finally(() => {
refreshPromise = null; // Clear lock when done
});

return refreshPromise;
}

Use this in your API client instead of calling refresh directly.

Step 7: Logout — Clean Everything

// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyRefreshToken } from '@/lib/auth/tokenService';
import { invalidateAllUserTokens } from '@/lib/auth/refreshTokenStore';
import { createHash } from 'crypto';
export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get('refresh_token')?.value;

if (refreshToken) {
try {
const payload = await verifyRefreshToken(refreshToken);
// Invalidate ALL sessions for this user
await invalidateAllUserTokens(payload.userId);
} catch {
// Token invalid — still proceed with cookie clearing
}
}

const response = NextResponse.json({ success: true });
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');

return response;
}

Security Checklist

RequirementImplementationTokens never in JShttpOnly: true cookiesCSRF protectionsameSite: 'lax' + CORSToken theft detectionRotation + family invalidationMinimal cookie exposurepath: '/api/auth' on refresh tokenSecure transmissionsecure: true in productionRace condition safeRefresh lock singletonHTTPS enforcedMiddleware redirect

Wrapping Up

Refresh token rotation is one of those things that’s easy to get wrong and expensive to fix after a breach. The pattern here — httpOnly cookies, rotation with family invalidation, middleware-level silent refresh — covers the attack vectors that matter.

The setup takes a few hours to implement correctly. The alternative is a bug bounty payout and an apology email to your users.

Building auth in Next.js and hitting edge cases? Drop them in the comments — happy to dig in.

I’m a Full-Stack Developer specializing in React, Next.js, and Node.js.
👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/

Author card for Chirag Mehta, Full-Stack Developer specializing in React, Next.js and Node.js. Links: github.com/chiragmehta900, linkedin.com/in/chiragmehta900, medium.com/@chiragmehta900.
Chirag Mehta

Originally published on Medium

Clap, comment or follow along there

Read on Medium →