troubleshooting2024-09-30·12 min·265/348

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 claims

The 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

  1. 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.

  2. Use updateAge to refresh tokens proactively. Setting updateAge to a shorter interval than maxAge ensures tokens are refreshed before they expire.

  3. Test session behavior across tabs and windows. Session synchronization between tabs is a common source of user confusion.

  4. Implement proper session cleanup. Expired database sessions accumulate over time and can cause performance degradation.

  5. 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.