Fixing next-auth Session Persistence Issues
How to resolve session loss, JWT expiration, and database session problems with next-auth in Next.js
Fixing next-auth Session Persistence Issues
Introduction
Session management is one of the most critical parts of any authenticated application. When sessions expire unexpectedly, users are logged out mid-task, and support tickets pile up. After implementing next-auth on four production applications, I have seen every possible session persistence issue.
The most common scenario: a user logs in, navigates around for 15 minutes, and suddenly finds themselves on the login page. The session appears to vanish. This post covers the root causes and permanent solutions.
Environment
- OS: Windows 11
- Node.js: v20.10.0
- Next.js: 14.0.4
- next-auth: 4.24.5
- Database: PostgreSQL with Prisma
- Adapter: @next-auth/prisma-adapter
Problem
Users experienced intermittent session loss:
# Server logs
[Auth] Session expired for user user@example.com
[Auth] JWT token expired at 2024-09-30T10:30:00Z
[Auth] Refreshing session failed: Token contains invalid claimsThe client received 401 errors on API calls:
{
"error": "Unauthorized",
"message": "Session has expired"
}In the browser, the next-auth.session-token cookie was present but contained an expired JWT.
Analysis
The root causes were:
1. JWT expiration mismatch. The JWT expiresIn was set to 30 minutes, but the database session record had a 7-day expiry. When the JWT expired, the refresh attempt failed because the session strategy was misconfigured.
2. Database session cleanup. The Prisma adapter creates session records in the database. Without periodic cleanup, expired sessions accumulated and caused conflicts during refresh.
3. Missing adapter configuration. When using JWT strategy, the adapter is still needed for account linking but sessions are not stored in the database. Mixing strategies without understanding the implications causes failures.
4. Clock skew. JWT timestamps are validated against the server clock. A small clock difference between the auth server and API server can cause premature expiration.
Solution
Fix 1: Consistent session configuration
// [...nextauth].ts
import NextAuth, { type NextAuthOptions } from 'next-auth'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt', // Use JWT for stateless sessions
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // Refresh token every 24 hours
},
jwt: {
maxAge: 30 * 24 * 60 * 60, // Match session maxAge
},
pages: {
signIn: '/login',
error: '/auth/error',
},
callbacks: {
async jwt({ token, user, trigger }) {
// Add user ID to token on initial sign in
if (user) {
token.id = user.id
}
// Handle session update trigger
if (trigger === 'update') {
// Refresh any data you need
const updatedUser = await prisma.user.findUnique({
where: { id: token.id as string },
})
if (updatedUser) {
token.name = updatedUser.name
token.email = updatedUser.email
}
}
return token
},
async session({ session, token }) {
// Send token to client session
if (session.user) {
session.user.id = token.id as string
}
return session
},
},
}
export default NextAuth(authOptions)Fix 2: Proper session hook usage
// hooks/useSession.ts
'use client'
import { useSession as useNextAuthSession } from 'next-auth/react'
export function useSession() {
const { data: session, status, update } = useNextAuthSession()
return {
session,
isLoading: status === 'loading',
isAuthenticated: status === 'authenticated',
update, // Function to trigger session refresh
}
}// components/UserProfile.tsx
'use client'
import { useSession } from '@/hooks/useSession'
export function UserProfile() {
const { session, isLoading, update } = useSession()
if (isLoading) return Loading...
if (!session) {
return Please sign in
}
return (
Name: {session.user.name}
Email: {session.user.email}
)
}Fix 3: API route session verification
// app/api/protected/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { NextResponse } from 'next/server'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Session is valid, proceed
return NextResponse.json({
user: session.user,
message: 'Access granted',
})
}Fix 4: Periodic session cleanup
// scripts/cleanup-sessions.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function cleanupExpiredSessions() {
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const deleted = await prisma.session.deleteMany({
where: {
expires: {
lt: thirtyDaysAgo,
},
},
})
console.log(`Cleaned up ${deleted.count} expired sessions`)
// Also clean up expired verification tokens
const deletedTokens = await prisma.verificationToken.deleteMany({
where: {
expires: {
lt: new Date(),
},
},
})
console.log(`Cleaned up ${deletedTokens.count} expired verification tokens`)
}
cleanupExpiredSessions()
.catch(console.error)
.finally(() => prisma.$disconnect())Lessons Learned
Keep JWT and database session maxAge in sync. When they differ, refresh attempts fail because the database thinks the session is expired while the JWT is still valid, or vice versa.
Use
updateAgeto refresh tokens proactively. SettingupdateAgeto a shorter interval thanmaxAgeensures tokens are refreshed before they expire.Test session behavior across tabs and windows. Session synchronization between tabs is a common source of user confusion.
Implement proper session cleanup. Expired database sessions accumulate over time and can cause performance degradation.
Always use HTTPS in production. Session tokens transmitted over HTTP can be intercepted and replayed, regardless of how well they are configured.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.