Skip to main content

NextJS Authentication Troubleshooting: 10 Issues Solved

Authentication is complex, and small misconfigurations cascade into confusing errors. A user is logged in locally but not in production. A session cookie is never set. Middleware runs on some routes but not others. This troubleshooting guide covers the 10 most common Next.js authentication problems and their solutions, with step-by-step debugging techniques.

Symptom: User logs in, but no cookie appears in DevTools.

Root causes:

  • setSessionCookie() is not being called after login.
  • secure: true on localhost (HTTP).
  • Cookie domain or path mismatch.

Debug steps:

// Add console logging to see if setSessionCookie is called
export async function POST(req: NextRequest) {
const { email, password } = await req.json();

// Validate credentials
if (email !== '[email protected]' || password !== 'password123') {
console.log('[LOGIN] Invalid credentials for:', email);
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}

const sessionId = await createSession('user-123', 'user');
console.log('[LOGIN] Created session:', sessionId);

await setSessionCookie(sessionId);
console.log('[LOGIN] Set session cookie');

return NextResponse.json({ success: true });
}

Check the server logs:

npm run dev
# Make a login request and look for [LOGIN] messages

Solution: Ensure setSessionCookie() is always called:

// Correct: setSessionCookie must be awaited
const sessionId = await createSession('user-123', 'user');
await setSessionCookie(sessionId);
return NextResponse.json({ success: true });

For localhost, disable the secure flag:

export async function setSessionCookie(sessionId: string) {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Allow HTTP on localhost
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60,
path: '/',
});
}

Issue 2: Middleware Not Running on Certain Routes

Symptom: Middleware runs on /dashboard but not /admin.

Root cause: The config.matcher glob does not include the route.

Debug steps:

// middleware.ts
export const config = {
// This matcher only runs on /dashboard, not /admin
matcher: ['/dashboard/:path*'],
};

The glob pattern must include all routes you want to protect:

export const config = {
// This matches /dashboard, /admin, and /settings
matcher: ['/(dashboard|admin|settings)/:path*'],
};

Or use a broader pattern:

export const config = {
// Exclude only static files
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Issue 3: Session Expires Too Quickly

Symptom: User is logged in for 5 minutes, then gets redirected to login.

Root causes:

  • Session maxAge is too short.
  • The session is not being refreshed on active requests.
  • The expires_at column in the database is incorrect.

Debug steps:

// Add logging to getSession
export async function getSession(sessionId: string) {
const result = await pool.query(
'SELECT user_id, role, expires_at FROM sessions WHERE id = $1',
[sessionId]
);

if (result.rows.length === 0) {
console.log('[SESSION] Session not found:', sessionId);
return null;
}

const session = result.rows[0];
const expiresAt = new Date(session.expires_at);
const now = new Date();

console.log('[SESSION] Session expires at:', expiresAt, 'Now:', now, 'Valid:', expiresAt > now);

if (expiresAt < now) {
await pool.query('DELETE FROM sessions WHERE id = $1', [sessionId]);
console.log('[SESSION] Session expired, deleted');
return null;
}

return { userId: session.user_id, role: session.role, expiresAt: expiresAt.getTime() };
}

Check the logs and verify expires_at is in the future. If all sessions expire at the same time, the expires_at calculation is wrong:

// WRONG: expiry is 7 days from server start, not from login
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

// Each session calculation is independent, but they should all be ~ 7 days from now
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

Solution: Implement sliding expiry in middleware:

export async function middleware(request: NextRequest) {
const sessionId = request.cookies.get('next_session')?.value;

if (sessionId) {
const session = await getSession(sessionId);
if (session) {
// Refresh session if less than 1 hour remains
const timeRemaining = session.expiresAt - Date.now();
if (timeRemaining < 60 * 60 * 1000) {
await refreshSession(sessionId);
console.log('[MIDDLEWARE] Refreshed session');
}
}
}
}

Issue 4: CORS Errors When Calling API Routes

Symptom: API requests fail with CORS error; error shows No 'Access-Control-Allow-Origin' header.

Root cause: The request is cross-origin (different domain/port), and your API does not allow it.

Debug steps: Check the request origin:

export async function POST(req: NextRequest) {
const origin = req.headers.get('origin');
console.log('[CORS] Request from origin:', origin);

if (origin && !allowedOrigins.includes(origin)) {
return NextResponse.json(
{ error: 'CORS not allowed' },
{ status: 403 }
);
}

return NextResponse.json({ success: true });
}

Solution: For same-origin requests (your frontend calling your own API), ensure credentials are sent:

// Frontend: fetch with credentials
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies
body: JSON.stringify({ email, password }),
});

For cross-origin requests, add CORS headers:

export async function POST(req: NextRequest) {
const response = NextResponse.json({ success: true });
response.headers.set('Access-Control-Allow-Origin', 'https://frontend.com');
response.headers.set('Access-Control-Allow-Credentials', 'true');
return response;
}

Issue 5: User Role Not Updated After Database Change

Symptom: Admin updates user role in the database, but the user still sees their old role.

Root cause: The session stores the old role and is not refreshed.

Solution: Clear the user's session after updating their role:

// app/api/admin/users/[id]/role/route.ts
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const { role } = await req.json();

// Update user role in database
await pool.query('UPDATE users SET role = $1 WHERE id = $2', [role, params.id]);

// Clear the user's sessions so they refresh on next request
await pool.query('DELETE FROM sessions WHERE user_id = $1', [params.id]);

console.log('[ADMIN] Updated role for user:', params.id, 'to:', role);

return NextResponse.json({ success: true });
}

Issue 6: Middleware Validation Passes Locally but Fails in Production

Symptom: Login works on localhost:3000 but not on Vercel or Docker.

Root cause: Environment variables are not set in production.

Debug steps:

// middleware.ts
export async function middleware(request: NextRequest) {
const sessionId = request.cookies.get('next_session')?.value;
console.log('[MIDDLEWARE] Database URL:', process.env.DATABASE_URL ? 'SET' : 'NOT SET');
console.log('[MIDDLEWARE] Session ID:', sessionId);

if (!sessionId) {
console.log('[MIDDLEWARE] No session, redirecting to login');
return NextResponse.redirect(new URL('/login', request.url));
}

try {
const session = await getSession(sessionId);
console.log('[MIDDLEWARE] Session:', session ? 'VALID' : 'INVALID');
} catch (error) {
console.error('[MIDDLEWARE] Error validating session:', error);
}
}

Check the production logs:

# Vercel
vercel logs

# Docker
docker logs <container-id>

Solution: Verify all environment variables are set in your deployment platform. For Vercel:

vercel env list
# Should show DATABASE_URL, SESSION_ENCRYPTION_KEY, etc.

Issue 7: cookies() Function Errors in Middleware

Symptom: Error: cookies() is not allowed in this context.

Root cause: Using cookies() instead of request.cookies in middleware.

Wrong:

// Middleware cannot use cookies() directly
export async function middleware(request: NextRequest) {
const sessionId = (await cookies()).get('next_session')?.value; // ERROR
}

Correct:

export async function middleware(request: NextRequest) {
const sessionId = request.cookies.get('next_session')?.value; // Correct
}

Issue 8: redirect() Not Working in Server Components

Symptom: redirect() is called but the page does not redirect.

Root cause: redirect() must be imported from next/navigation, and it must be a top-level call.

Wrong:

// Import from wrong module
import { redirect } from 'next/server'; // Wrong

export default function Page() {
if (!authenticated) {
redirect('/login'); // May not work
}
}

Correct:

// Import from correct module
import { redirect } from 'next/navigation'; // Correct

export default async function Page() {
const user = await getCurrentUser();
if (!user) {
redirect('/login'); // Works
}
}

Issue 9: JWT Token Validation Fails in Production

Symptom: JWTs work locally but fail with invalid signature in production.

Root cause: process.env.JWT_SECRET is different between environments.

Solution: Ensure the JWT secret is identical in all environments:

# Generate once
openssl rand -hex 32
# Result: abc123...xyz789

# Set in .env.local, .env.production, and production environment variables
JWT_SECRET=abc123...xyz789

Verify the secret in middleware:

export async function middleware(request: NextRequest) {
console.log('[JWT] Secret length:', process.env.JWT_SECRET?.length);

// The length should be the same everywhere
// If it differs, the secret is not consistent
}

Issue 10: Session Data Lost After Deployment

Symptom: All users are logged out after deploying a new version.

Root cause: Sessions are stored in an in-memory map that is cleared when the container restarts.

Solution: Use a persistent session store (database or Redis):

// lib/sessions.ts - Use database instead of Map
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function createSession(userId: string, role: string) {
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

await pool.query(
'INSERT INTO sessions (id, user_id, role, expires_at) VALUES ($1, $2, $3, $4)',
[sessionId, userId, role, expiresAt]
);

return sessionId;
}

Alternatively, use Redis for faster session lookups:

// lib/sessions-redis.ts
import { getRedis } from './redis';

export async function createSession(userId: string, role: string) {
const sessionId = crypto.randomUUID();
const redis = await getRedis();

await redis.setEx(
`session:${sessionId}`,
7 * 24 * 60 * 60, // 7 days in seconds
JSON.stringify({ userId, role })
);

return sessionId;
}

Key Takeaways

  • Always check server logs first when debugging auth issues.
  • Use console.log statements to trace the auth flow.
  • Verify that all required environment variables are set in production.
  • Sessions must be stored in a persistent database, not in-memory.
  • CORS errors are usually caused by credentials not being sent; add credentials: 'include' to fetch.
  • Role changes in the database must invalidate the user's session.
  • Use request.cookies in middleware, not cookies().

Frequently Asked Questions

Why does my session expire after I close the browser?

Check the cookie settings. If path is not / or maxAge is not set, the cookie might be a "session cookie" that expires when the browser closes. Always set explicit maxAge and path values.

How do I test authentication without logging in every time?

Set a test user's session ID directly in your browser's DevTools console: document.cookie = "next_session=test-session-id; path=/". Or use the browser's Application tab to edit cookies directly.

What if I cannot access the server logs to debug?

Add extensive client-side logging and send data to an external service (Sentry, LogRocket, etc.). Or add a debug endpoint that returns diagnostic info: GET /api/debug returns environment variables and session info.

How do I prevent session hijacking?

Use HTTPS, HTTP-only cookies, SameSite flag, and session rotation. After sensitive actions (password change, role change), invalidate old sessions and issue new ones.

Further Reading