Why Your Next.js Auth Keeps Logging Users Out (And How to Fix Token Refresh)
You set up authentication in your Next.js app. Login works. Dashboard loads. Everything looks great — for about an hour. Then users start complaining: "I was using the app and suddenly it kicked me back to the login page."
You check the logs. No errors. You test locally. Works fine. But in production, users are getting silently logged out, losing unsaved work, and getting frustrated.
I've debugged this exact issue across three different projects. The root cause is almost always the same: your access token expires and your app has no idea how to refresh it.
Why Tokens Expire Silently
Most Next.js auth setups use JWTs (JSON Web Tokens), either through NextAuth/Auth.js or a custom implementation. Here's the lifecycle that causes problems:
- User logs in → Server issues an access token (lives 15-60 minutes)
- User keeps using the app → Frontend sends the token with API requests
- Token expires → API returns
401 Unauthorized - Frontend has no refresh logic → User sees a blank page, an error, or gets redirected to login
The frustrating part? Everything worked during development because you kept refreshing the page, which triggered a new login. In production, users stay on a single page for hours.
Where Developers Go Wrong
Mistake 1: Setting Long-Lived Access Tokens
// ❌ "Just make the token last forever"
const token = jwt.sign(payload, secret, { expiresIn: '30d' });This "solves" the problem by creating a massive security hole. If the token leaks (XSS, stolen device, shared link), an attacker has access for a month. Access tokens should live 15 minutes, max.
Mistake 2: Refreshing on 401 Without Queuing
// ❌ Race condition — multiple tabs/requests all try to refresh
api.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
const newToken = await refreshToken(); // Every failed request triggers this
error.config.headers.Authorization = `Bearer ${newToken}`;
return api(error.config);
}
});If three API calls fail at the same time, your app sends three refresh requests simultaneously. Most refresh token implementations invalidate the token after first use — so two of those requests fail, and your user gets logged out anyway.
Mistake 3: Storing Tokens in localStorage
// ❌ Accessible to any JavaScript on the page
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);Any XSS vulnerability now gives attackers both your access token AND your refresh token. Game over. Refresh tokens should never be accessible to client-side JavaScript.
The Proper Solution: Server-Side Token Refresh
Here's the architecture that actually works in production:
The Flow
User Request → Next.js Middleware → Check Token →
├─ Token valid → Forward request
└─ Token expired → Use refresh token (httpOnly cookie) →
├─ Refresh succeeds → Set new tokens, forward request
└─ Refresh fails → Redirect to login
The key insight: token refresh happens on the server, before the page even renders. The user never sees a loading state or error.
Step 1: Store Tokens in httpOnly Cookies
When your auth API returns tokens, set them as httpOnly cookies in your Next.js API route:
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, password } = await request.json();
// Call your backend auth API
const authResponse = await fetch(`${process.env.API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!authResponse.ok) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const { accessToken, refreshToken, expiresIn } = await authResponse.json();
const response = NextResponse.json({ success: true });
// httpOnly = JavaScript can't access these
// secure = HTTPS only
// sameSite = CSRF protection
response.cookies.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: expiresIn,
path: '/',
});
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return response;
}Step 2: Build the Middleware Token Refresh
This is where the magic happens. Next.js middleware runs before every request. We intercept it, check the token, and refresh if needed:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const PROTECTED_PATHS = ['/dashboard', '/settings', '/profile'];
const AUTH_PATHS = ['/login', '/register'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const accessToken = request.cookies.get('access_token')?.value;
const refreshToken = request.cookies.get('refresh_token')?.value;
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p));
// Redirect logged-in users away from auth pages
if (isAuthPage && accessToken) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Public routes — let them through
if (!isProtected) {
return NextResponse.next();
}
// No tokens at all — redirect to login
if (!accessToken && !refreshToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Access token exists — let the request proceed
if (accessToken) {
return NextResponse.next();
}
// Access token expired but refresh token exists — try to refresh
if (!accessToken && refreshToken) {
try {
const refreshResponse = await fetch(
`${process.env.API_URL}/auth/refresh`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
}
);
if (!refreshResponse.ok) throw new Error('Refresh failed');
const { accessToken: newAccess, refreshToken: newRefresh, expiresIn } =
await refreshResponse.json();
// Create response and set new cookies
const response = NextResponse.next();
response.cookies.set('access_token', newAccess, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: expiresIn,
path: '/',
});
response.cookies.set('refresh_token', newRefresh, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
return response;
} catch {
// Refresh failed — clear cookies and redirect to login
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return response;
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*', '/login', '/register'],
};Step 3: Proxy API Calls Through Next.js Routes
Don't call your backend directly from the client. Proxy through Next.js API routes to attach the token server-side:
// app/api/proxy/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const accessToken = request.cookies.get('access_token')?.value;
if (!accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const apiPath = params.path.join('/');
const response = await fetch(`${process.env.API_URL}/${apiPath}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}Now your frontend calls /api/proxy/users/me instead of https://api.example.com/users/me, and the token is attached automatically. The client never touches the token.
Handling the Edge Case: Token Expires Mid-Session
Even with middleware, a token can expire while a user is on a page making API calls. Add a client-side interceptor as a safety net:
// lib/api.ts
export async function apiFetch(path: string, options?: RequestInit) {
const response = await fetch(`/api/proxy/${path}`, options);
if (response.status === 401) {
// Try refreshing by hitting a refresh endpoint
const refreshResult = await fetch('/api/auth/refresh', { method: 'POST' });
if (refreshResult.ok) {
// Retry the original request
return fetch(`/api/proxy/${path}`, options);
}
// Refresh failed — redirect to login
window.location.href = '/login';
}
return response;
}Key Takeaways
- Access tokens should expire in 15 minutes. Long-lived tokens are a security risk.
- Store tokens in httpOnly cookies. Never in localStorage or sessionStorage.
- Refresh tokens in Next.js middleware. The user never sees the refresh happen.
- Proxy API calls through Next.js routes so the client never touches tokens.
- Add a client-side fallback for tokens that expire during active sessions.
- Never send multiple concurrent refresh requests. Queue them or use a mutex.
With NextAuth / Auth.js
If you're using NextAuth, the same concepts apply. NextAuth stores the session in a JWT by default. You can customize the jwt callback to handle refresh tokens:
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
// Return token if not expired
if (Date.now() < (token.expiresAt as number) * 1000) {
return token;
}
// Token expired — refresh it
return await refreshAccessToken(token);
},
}But understand that NextAuth's session callback runs on every request in App Router, which is exactly where you'd plug in the refresh logic.
I've shipped this pattern across multiple production apps. The first time you get it right, you'll never go back to "why did my session just die?" debugging sessions again.
Find me at: itszain.dev