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.
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:
In Next.js, especially in combination with NextAuth.js, we have access to both approaches.
NextAuth.js offers two main session management strategies:
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:
Wady:
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:
Wady:
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};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};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}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}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}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.
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}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}When implementing session and token management, keep in mind several key security aspects:
Use strong secrets - ensure that
NEXTAUTH_SECRET is a long, random string of charactersSecure cookies - use flags:
HttpOnly: prevents JavaScript access to the cookieSecure: requires HTTPSSameSite: protects against CSRF attacksSet appropriate lifetime - tokens/sessions should not be valid for too long a period
Limit data in the token - the JWT token should contain minimum necessary information
Implement session revocation - allow users to log out from all devices
Monitor suspicious activity - detect repeated login attempts and unusual access patterns
Secret rotation - periodically change secrets used to sign tokens
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});In the Next.js App Router architecture, it is important to follow these principles when implementing authentication:
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// 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// 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}In this module, we discussed in detail how to manage user sessions and tokens in Next.js applications:
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.