We use cookies to enhance your experience on the site
CodeWorlds

User sessions and token management

In previous modules, we covered the basics of authentication in Next.js and the implementation of login with external providers. Now we will delve into a key aspect of every authentication system: managing user sessions and tokens. Understanding these mechanisms is essential for building secure and efficient web applications.

Session management basics

A user session is a mechanism that allows the application to "remember" a logged-in user during navigation between pages and requests. In modern web applications, sessions are implemented in two main ways:

  1. Server-side sessions - the session identifier is stored in a cookie, and the session data on the server
  2. Stateless sessions (e.g. JWT) - all necessary information stored in the token, often in a cookie

In Next.js, especially in combination with NextAuth.js, we have access to both approaches.

Sessions in NextAuth.js

NextAuth.js offers two main session management strategies:

1. JWT strategy (default)

In this strategy, all session information is stored in a JWT (JSON Web Token) that is saved in a cookie:

1// JWT strategy configuration in NextAuth.js
2export const authOptions = {
3  providers: [
4    // Provider configuration...
5  ],
6  session: {
7    strategy: "jwt",
8    maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
9    updateAge: 24 * 60 * 60,    // 24 hours in seconds
10  },
11  jwt: {
12    secret: process.env.NEXTAUTH_SECRET,
13    // You can also use private/public keys instead of a simple secret
14    // encryption: true, // Enables JWT encryption
15    // signingKey: process.env.JWT_SIGNING_PRIVATE_KEY,
16    // encryptionKey: process.env.JWT_ENCRYPTION_KEY,
17  }
18};

Zalety:

  • Better scalability - the server does not need to store session data
  • Works well in a serverless environment
  • Simpler to implement

Wady:

  • Limited data size (tokens should not be too large)
  • More difficult to invalidate a token before expiration
  • Sensitive data is stored in the token (although encrypted)

2. Database strategy

In this strategy, only the session identifier is stored in the cookie, and the rest of the session data is stored in the database:

1// Database strategy configuration in NextAuth.js
2import { PrismaAdapter } from "@auth/prisma-adapter";
3import { PrismaClient } from "@prisma/client";
4
5const prisma = new PrismaClient();
6
7export const authOptions = {
8  providers: [
9    // Provider configuration...
10  ],
11  adapter: PrismaAdapter(prisma),
12  session: {
13    strategy: "database",
14    maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
15    updateAge: 24 * 60 * 60,    // 24 hours in seconds
16  }
17};

Zalety:

  • Easier session invalidation (can remove the entry from the database)
  • No limits on data amount
  • Safer for sensitive data

Wady:

  • Requires database access on every request
  • Slightly more difficult scalability
  • A database adapter is needed

JWT token structure in NextAuth.js

When we use the JWT strategy, NextAuth.js creates a token containing basic user information:

1{
2  "name": "John Doe",
3  "email": "john@example.com",
4  "sub": "cl5ab1gxk0001t8kgxs4t8j1h", // user identifier
5  "iat": 1658309600, // token issued time
6  "exp": 1660901600, // token expiration time
7  "jti": "4b32c6a4-d2c5-4f1a-a2b3-5c84c1d94208" // unique token identifier
8}

We can extend the token with additional data using the callback

jwt
:

1export const authOptions = {
2  //
3  callbacks: {
4    async jwt({ token, user, account }) {
5      // On first login we have access to the user object
6      if (user) {
7        token.role = user.role;
8        token.userId = user.id;
9        token.provider = account?.provider;
10      }
11      
12      // Here we can add token refresh logic
13      // e.g. checking if the OAuth access token has not expired
14      
15      return token;
16    },
17    
18    async session({ session, token }) {
19      // Add data from the token to the session object (available on the client)
20      if (session.user) {
21        session.user.role = token.role;
22        session.user.id = token.userId;
23        session.user.provider = token.provider;
24      }
25      
26      return session;
27    }
28  }
29};

Configuring session cookies

By default, NextAuth.js configures session cookies according to best security practices, but we can customize these settings:

1export const authOptions = {
2  //
3  cookies: {
4    sessionToken: {
5      name: `__Secure-next-auth.session-token`,
6      options: {
7        httpOnly: true,
8        sameSite: "lax",
9        path: "/",
10        secure: true,  // Set to false during local testing without HTTPS
11        maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
12      },
13    },
14    // You can also configure other cookies used by NextAuth.js
15    callbackUrl: {
16      name: `__Secure-next-auth.callback-url`,
17      options: { /* ... */ },
18    },
19    csrfToken: {
20      name: `__Host-next-auth.csrf-token`,
21      options: { /* ... */ },
22    },
23  },
24};

Client-side session management

In client components we can use the hook

useSession
to access session data and its status:

1'use client';
2
3import { useSession } from 'next-auth/react';
4
5export default function ProfileComponent() {
6  const { data: session, status } = useSession();
7  
8  if (status === 'loading') {
9    return <p>Loading...</p>;
10  }
11  
12  if (status === 'unauthenticated') {
13    return <p>Access only for logged-in users</p>;
14  }
15  
16  return (
17    <div>
18      <h1>Profile</h1>
19      <p>Hello, {session.user.name}!</p>
20      <p>Email: {session.user.email}</p>
21      {session.user.role === 'admin' && (
22        <p>You have administrator permissions</p>
23      )}
24    </div>
25  );
26}

We can also require a session using the option

required
:

1'use client';
2
3import { useSession } from 'next-auth/react';
4import { redirect } from 'next/navigation';
5
6export default function ProtectedComponent() {
7  const { data: session, status } = useSession({
8    required: true,
9    onUnauthenticated() {
10      redirect('/auth/login?callbackUrl=/profile');
11    },
12  });
13  
14  // When required is set, we don't need to check 'unauthenticated' status
15  if (status === 'loading') {
16    return <p>Loading...</p>;
17  }
18  
19  return (
20    <div>
21      <h1>Protected content</h1>
22      <p>This page is visible only to logged-in users.</p>
23    </div>
24  );
25}

Server-side session management

In server components or Route Handlers we can use

getServerSession
to access session data:

1// Server Component
2import { getServerSession } from "next-auth/next";
3import { redirect } from "next/navigation";
4import { authOptions } from "@/app/api/auth/[...nextauth]/route";
5
6export default async function ServerProfilePage() {
7  const session = await getServerSession(authOptions);
8  
9  if (!session) {
10    redirect('/auth/login?callbackUrl=/profile');
11  }
12  
13  return (
14    <div>
15      <h1>Profile (rendered on the server)</h1>
16      <p>Hello, {session.user.name}!</p>
17      <p>Email: {session.user.email}</p>
18      {/* Content visible only to logged-in users */}
19    </div>
20  );
21}
22
23// Route Handler (API Route)
24import { getServerSession } from "next-auth/next";
25import { NextResponse } from "next/server";
26import { authOptions } from "@/app/api/auth/[...nextauth]/route";
27
28export async function GET() {
29  const session = await getServerSession(authOptions);
30  
31  if (!session) {
32    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
33  }
34  
35  // Fetch data specific to the logged-in user
36  const userData = await fetchUserData(session.user.id);
37  
38  return NextResponse.json(userData);
39}

Refresh tokens and OAuth access management

If we use OAuth providers, we may want to store and refresh access tokens to use the providers' APIs:

1export const authOptions = {
2  //
3  callbacks: {
4    async jwt({ token, user, account }) {
5      // On first login, save access/refresh tokens
6      if (account && user) {
7        token.accessToken = account.access_token;
8        token.refreshToken = account.refresh_token;
9        token.accessTokenExpires = account.expires_at * 1000; // Convert to milliseconds
10        token.userId = user.id;
11        return token;
12      }
13      
14      // If the access token has not expired, return it
15      if (Date.now() < token.accessTokenExpires) {
16        return token;
17      }
18      
19      // If the access token has expired, refresh it
20      return refreshAccessToken(token);
21    },
22    
23    async session({ session, token }) {
24      // Pass the access token to the client (but not the refresh token!)
25      session.accessToken = token.accessToken;
26      session.user.id = token.userId;
27      
28      return session;
29    },
30  },
31};
32
33async function refreshAccessToken(token) {
34  try {
35    // Example of refreshing a Google OAuth token
36    const url = "https://oauth2.googleapis.com/token";
37    const response = await fetch(url, {
38      headers: { "Content-Type": "application/x-www-form-urlencoded" },
39      method: "POST",
40      body: new URLSearchParams({
41        client_id: process.env.GOOGLE_CLIENT_ID,
42        client_secret: process.env.GOOGLE_CLIENT_SECRET,
43        grant_type: "refresh_token",
44        refresh_token: token.refreshToken,
45      }),
46    });
47
48    const refreshedTokens = await response.json();
49
50    if (!response.ok) {
51      throw refreshedTokens;
52    }
53
54    return {
55      ...token,
56      accessToken: refreshedTokens.access_token,
57      accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
58      // Keep the refresh token unless we received a new one
59      refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
60    };
61  } catch (error) {
62    console.error("Error refreshing access token", error);
63    // The refresh token may have expired, the user will need to log in again
64    return {
65      ...token,
66      error: "RefreshAccessTokenError",
67    };
68  }
69}

Logging out and session invalidation

NextAuth.js provides the function

signOut()
for logging out:

1'use client';
2
3import { signOut } from 'next-auth/react';
4
5export function LogoutButton() {
6  return (
7    <button 
8      onClick={() => signOut({ callbackUrl: '/' })} 
9      className="bg-red-500 text-white px-4 py-2 rounded"
10    >
11      Log out
12    </button>
13  );
14}

In the case of the database-based strategy, we can manually invalidate a session by removing it from the database:

1// app/api/auth/invalidate-sessions/route.ts
2import { NextResponse } from "next/server";
3import { getServerSession } from "next-auth/next";
4import { authOptions } from "@/app/api/auth/[...nextauth]/route";
5import { prisma } from "@/lib/prisma";
6
7export async function POST(request: Request) {
8  const session = await getServerSession(authOptions);
9  
10  if (!session || session.user.role !== 'admin') {
11    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
12  }
13  
14  const { userId } = await request.json();
15  
16  // Remove all sessions of a user with the given ID
17  await prisma.session.deleteMany({
18    where: {
19      userId,
20    },
21  });
22  
23  return NextResponse.json({ success: true });
24}

In the case of the JWT strategy, this is more difficult because tokens are self-contained. However, we can implement a token "blacklist" or a secret rotation mechanism:

1// Blacklist implementation for the JWT token
2const revokedTokens = new Set();
3
4export const authOptions = {
5  //
6  callbacks: {
7    async jwt({ token }) {
8      // Check if the token is on the blacklist
9      if (revokedTokens.has(token.jti)) {
10        return null; // Token invalidated
11      }
12      return token;
13    },
14  },
15  events: {
16    async signOut({ token }) {
17      // Add the token to the blacklist on logout
18      if (token?.jti) {
19        revokedTokens.add(token.jti);
20      }
21    },
22  },
23};

This simple solution only works for a single server. For multiple servers or serverless solutions, we would need to use a shared data store (e.g. Redis) to store the list of invalidated tokens.

Inactivity detection and automatic logout

We can implement a mechanism to detect user inactivity and automatic logout:

1'use client';
2
3import { useSession, signOut } from 'next-auth/react';
4import { useEffect, useState } from 'react';
5
6export function InactivityDetector({ timeoutMinutes = 30 }) {
7  const { data: session } = useSession();
8  
9  useEffect(() => {
10    if (!session) return;
11    
12    let timeout;
13    const timeoutMs = timeoutMinutes * 60 * 1000;
14    
15    const resetTimeout = () => {
16      if (timeout) clearTimeout(timeout);
17      timeout = setTimeout(() => {
18        signOut({ callbackUrl: '/auth/login?timeout=true' });
19      }, timeoutMs);
20    };
21    
22    // Reset the timer on every user activity
23    const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
24    
25    // Set the initial timer
26    resetTimeout();
27    
28    // Add event listeners
29    events.forEach(event => {
30      window.addEventListener(event, resetTimeout);
31    });
32    
33    return () => {
34      // Clear timer and listeners on unmount
35      if (timeout) clearTimeout(timeout);
36      events.forEach(event => {
37        window.removeEventListener(event, resetTimeout);
38      });
39    };
40  }, [session, timeoutMinutes]);
41  
42  // The component does not render anything visible
43  return null;
44}

Then you can add this component to the main application layout:

1// app/layout.tsx
2import { Providers } from "./providers";
3import { InactivityDetector } from "@/components/InactivityDetector";
4
5export default function RootLayout({
6  children,
7}: {
8  children: React.ReactNode;
9}) {
10  return (
11    <html lang="pl">
12      <body>
13        <Providers>
14          {children}
15          <InactivityDetector timeoutMinutes={30} />
16        </Providers>
17      </body>
18    </html>
19  );
20}

Handling multiple devices and sessions

If we use the database-based strategy, we can implement management of multiple sessions for the same user, allowing them to review active sessions and log out from specific devices:

1// app/settings/sessions/page.tsx
2import { getServerSession } from "next-auth/next";
3import { redirect } from "next/navigation";
4import { authOptions } from "@/app/api/auth/[...nextauth]/route";
5import { prisma } from "@/lib/prisma";
6import { SessionList } from "./SessionList";
7
8export default async function SessionsPage() {
9  const session = await getServerSession(authOptions);
10  
11  if (!session) {
12    redirect('/auth/login?callbackUrl=/settings/sessions');
13  }
14  
15  // Fetch all active user sessions
16  const userSessions = await prisma.session.findMany({
17    where: {
18      userId: session.user.id,
19      expires: {
20        gt: new Date(), // Only active sessions
21      },
22    },
23    orderBy: {
24      expires: 'desc',
25    },
26  });
27  
28  // Mark the current session
29  const currentSessionToken = session.user.sessionToken; // You must adjust to have access to the token
30  const enhancedSessions = userSessions.map(s => ({
31    ...s,
32    isCurrent: s.sessionToken === currentSessionToken,
33  }));
34  
35  return (
36    <div className="max-w-4xl mx-auto p-6">
37      <h1 className="text-2xl font-bold mb-6">Active sessions</h1>
38      <SessionList sessions={enhancedSessions} currentSessionId={session.user.id} />
39    </div>
40  );
41}
42
43// app/settings/sessions/SessionList.tsx (client component)
44'use client';
45
46import { useState } from 'react';
47import { useRouter } from 'next/navigation';
48
49export function SessionList({ sessions, currentSessionId }) {
50  const [isLoading, setIsLoading] = useState(false);
51  const [error, setError] = useState('');
52  const router = useRouter();
53  
54  const handleRevokeSession = async (sessionToken) => {
55    if (confirm('Are you sure you want to log out this device?')) {
56      setIsLoading(true);
57      setError('');
58      
59      try {
60        const response = await fetch('/api/auth/revoke-session', {
61          method: 'POST',
62          headers: {
63            'Content-Type': 'application/json',
64          },
65          body: JSON.stringify({ sessionToken }),
66        });
67        
68        if (!response.ok) {
69          throw new Error('Failed to invalidate session');
70        }
71        
72        // Refresh the page
73        router.refresh();
74      } catch (e) {
75        setError(e.message);
76      } finally {
77        setIsLoading(false);
78      }
79    }
80  };
81  
82  const formatDate = (dateString) => {
83    return new Date(dateString).toLocaleString();
84  };
85  
86  return (
87    <div>
88      {error && (
89        <div className="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg">
90          {error}
91        </div>
92      )}
93      
94      <div className="overflow-x-auto">
95        <table className="min-w-full divide-y divide-gray-200">
96          <thead className="bg-gray-50">
97            <tr>
98              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Device</th>
99              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last activity</th>
100              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expires</th>
101              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
102            </tr>
103          </thead>
104          <tbody className="bg-white divide-y divide-gray-200">
105            {sessions.map((session) => (
106              <tr key={session.id}>
107                <td className="px-6 py-4 whitespace-nowrap">
108                  <div className="flex items-center">
109                    <div className="ml-4">
110                      <div className="text-sm font-medium text-gray-900">
111                        {session.isCurrent ? 'Current device' : 'Other device'}
112                      </div>
113                    </div>
114                  </div>
115                </td>
116                <td className="px-6 py-4 whitespace-nowrap">
117                  <div className="text-sm text-gray-900">{formatDate(session.updatedAt)}</div>
118                </td>
119                <td className="px-6 py-4 whitespace-nowrap">
120                  <div className="text-sm text-gray-900">{formatDate(session.expires)}</div>
121                </td>
122                <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
123                  <button
124                    onClick={() => handleRevokeSession(session.sessionToken)}
125                    disabled={isLoading || session.isCurrent}
126                    className={`text-red-600 hover:text-red-900 ${(isLoading || session.isCurrent) ? 'opacity-50 cursor-not-allowed' : ''}`}
127                  >
128                    {session.isCurrent ? 'Current session' : 'Log out'}
129                  </button>
130                </td>
131              </tr>
132            ))}
133          </tbody>
134        </table>
135      </div>
136    </div>
137  );
138}
139
140// app/api/auth/revoke-session/route.ts
141import { NextResponse } from "next/server";
142import { getServerSession } from "next-auth/next";
143import { authOptions } from "@/app/api/auth/[...nextauth]/route";
144import { prisma } from "@/lib/prisma";
145
146export async function POST(request: Request) {
147  const session = await getServerSession(authOptions);
148  
149  if (!session) {
150    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
151  }
152  
153  const { sessionToken } = await request.json();
154  
155  // Find the session, making sure it belongs to the logged-in user
156  const sessionToRevoke = await prisma.session.findFirst({
157    where: {
158      sessionToken,
159      userId: session.user.id,
160    },
161  });
162  
163  if (!sessionToRevoke) {
164    return NextResponse.json(
165      { error: "Session not found or not owned by current user" },
166      { status: 404 }
167    );
168  }
169  
170  // Remove the session
171  await prisma.session.delete({
172    where: {
173      id: sessionToRevoke.id,
174    },
175  });
176  
177  return NextResponse.json({ success: true });
178}

Token and session security

When implementing session and token management, keep in mind several key security aspects:

  1. Use strong secrets - ensure that

    NEXTAUTH_SECRET
    is a long, random string of characters

  2. Secure cookies - use flags:

    • HttpOnly
      : prevents JavaScript access to the cookie
    • Secure
      : requires HTTPS
    • SameSite
      : protects against CSRF attacks
  3. Set appropriate lifetime - tokens/sessions should not be valid for too long a period

  4. Limit data in the token - the JWT token should contain minimum necessary information

  5. Implement session revocation - allow users to log out from all devices

  6. Monitor suspicious activity - detect repeated login attempts and unusual access patterns

  7. Secret rotation - periodically change secrets used to sign tokens

Session and token tests

Tests are a key element in ensuring the reliability of session and token mechanisms. Here is an example unit test for token refresh functionality:

1// __tests__/refreshToken.test.ts
2import { refreshAccessToken } from '@/lib/auth';
3import fetchMock from 'jest-fetch-mock';
4
5fetchMock.enableMocks();
6
7beforeEach(() => {
8  fetchMock.resetMocks();
9});
10
11describe('refreshAccessToken', () => {
12  it('should refresh the token successfully', async () => {
13    // Prepare a mock response from the OAuth server
14    fetchMock.mockResponseOnce(JSON.stringify({
15      access_token: 'new_access_token',
16      expires_in: 3600
17    }));
18    
19    const originalToken = {
20      accessToken: 'old_access_token',
21      refreshToken: 'refresh_token',
22      accessTokenExpires: Date.now() - 1000, // Expired token
23    };
24    
25    const refreshedToken = await refreshAccessToken(originalToken);
26    
27    expect(refreshedToken.accessToken).toBe('new_access_token');
28    expect(refreshedToken.refreshToken).toBe('refresh_token'); // Should keep the old refresh token
29    expect(refreshedToken.accessTokenExpires).toBeGreaterThan(Date.now());
30    expect(fetchMock).toHaveBeenCalledTimes(1);
31  });
32  
33  it('should handle refresh token errors', async () => {
34    // Simulate refresh error
35    fetchMock.mockRejectOnce(new Error('Failed to refresh token'));
36    
37    const originalToken = {
38      accessToken: 'old_access_token',
39      refreshToken: 'refresh_token',
40      accessTokenExpires: Date.now() - 1000,
41    };
42    
43    const result = await refreshAccessToken(originalToken);
44    
45    expect(result.error).toBe('RefreshAccessTokenError');
46    expect(result.accessToken).toBe('old_access_token'); // Should keep the old token
47  });
48});

Good practices in the context of Next.js App Router

In the Next.js App Router architecture, it is important to follow these principles when implementing authentication:

  1. Use middleware to protect entire directories or route groups:
1// middleware.ts
2import { NextResponse } from "next/server";
3import type { NextRequest } from "next/server";
4import { getToken } from "next-auth/jwt";
5
6export async function middleware(request: NextRequest) {
7  const token = await getToken({ req: request });
8  
9  // Check if the user has access to the requested page
10  if (!token) {
11    const url = new URL('/auth/login', request.url);
12    url.searchParams.set('callbackUrl', request.nextUrl.pathname);
13    return NextResponse.redirect(url);
14  }
15  
16  // Checking permissions based on path and role
17  if (request.nextUrl.pathname.startsWith('/admin') && token.role !== 'admin') {
18    return NextResponse.redirect(new URL('/dashboard', request.url));
19  }
20  
21  return NextResponse.next();
22}
23
24export const config = {
25  matcher: [
26    '/dashboard/:path*',
27    '/settings/:path*',
28    '/admin/:path*',
29    '/api/protected/:path*'
30  ],
31};
  1. Use Server Components where possible, to reduce the amount of client-side JavaScript code and improve the security of sensitive operations:
1// app/admin/dashboard/page.tsx (Server Component)
2import { getServerSession } from "next-auth/next";
3import { redirect } from "next/navigation";
4import { authOptions } from "@/app/api/auth/[...nextauth]/route";
5
6export default async function AdminDashboard() {
7  const session = await getServerSession(authOptions);
8  
9  if (!session || session.user.role !== 'admin') {
10    redirect('/');
11  }
12  
13  // Fetch administrative data directly on the server
14  // without exposing API keys or other sensitive information to the client
15  const adminData = await fetchAdminData();
16  
17  return (
18    <div>
19      <h1>Administrator panel</h1>
20      {/* Render administrator data */}
21    </div>
22  );
23}
  1. Use Route Handlers to secure API:
1// app/api/protected/user-data/route.ts
2import { getServerSession } from "next-auth/next";
3import { NextResponse } from "next/server";
4import { authOptions } from "@/app/api/auth/[...nextauth]/route";
5
6export async function GET() {
7  const session = await getServerSession(authOptions);
8  
9  if (!session) {
10    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11  }
12  
13  // Perform protected operations
14  const userData = await fetchUserData(session.user.id);
15  
16  // Return data without sensitive information
17  const sanitizedData = sanitizeUserData(userData);
18  
19  return NextResponse.json(sanitizedData);
20}

Summary

In this module, we discussed in detail how to manage user sessions and tokens in Next.js applications:

  1. We learned about different session strategies in NextAuth.js - JWT and database-based
  2. We learned how to extend tokens and sessions with our own data
  3. We understood how to secure cookies and configure session parameters
  4. We learned about token refresh mechanisms for OAuth providers
  5. We implemented multi-session management and logout from specific devices
  6. We discussed best security practices for tokens and sessions

Proper session and token management is a key aspect of web application security. A well-designed authentication system not only protects user data but also ensures a good user experience, allowing easy login, logout, and access management from different devices.

In the next module, we will deal with implementing a system of roles and permissions that will allow for more granular management of user access to different application features.

Go to CodeWorlds