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.
Issue 1: Session Cookie Not Being Set
Symptom: User logs in, but no cookie appears in DevTools.
Root causes:
setSessionCookie()is not being called after login.secure: trueon 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
maxAgeis too short. - The session is not being refreshed on active requests.
- The
expires_atcolumn 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.cookiesin middleware, notcookies().
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.