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:

  1. User logs in → Server issues an access token (lives 15-60 minutes)
  2. User keeps using the app → Frontend sends the token with API requests
  3. Token expires → API returns 401 Unauthorized
  4. 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

Related Articles

Your Next.js API Routes Are Wide Open — How to Add Rate Limiting Before You Get Abused

Most Next.js apps deploy API routes with zero protection against abuse. Here's how to add production-grade rate limiting using in-memory stores, Redis, and middleware — before someone hammers your endpoints.

8 min read

How to Show Real File Upload Progress in Flutter (Not Fake Progress Bars)

Most Flutter upload tutorials fake the progress bar. Here's how to track actual byte-level upload progress using Dio's multipart requests and stream transformers — with a production-ready implementation.

5 min read

How to Prevent Duplicate API Requests in Flutter (Debounce, Idempotency, and Queue Patterns)

Users double-tap buttons, retry on slow networks, and pull-to-refresh while a request is already in flight. Here's how to prevent duplicate submissions, wasted API calls, and corrupted data in Flutter apps.

7 min read