Sep 27, 2023
Nick Parsons
Session management allows users to stay logged in across multiple tabs devices and maintains security by tracking user sessions.
Session management is a concept that flies under the radar in most applications. It’s built into every authentication library you are using, and seamlessly allows users to stay logged in, use different tabs, and stay secure while they are using your app.
But because it is abstracted away by auth systems, it’s also opaque. How does session management work to keep track of your usage?
Here, we want to build session management in Next.js without using any authentication library to show you what is really happening under the hood.
This might seem like a trivial question, but it doesn’t have a trivial answer. One of the reasons that session management is often abstracted away from developers is that it’s a difficult concept to grok and implement.
A session is a series of interactions between a user and an application that occur within a given time frame.
A session is initiated when a user logs in or starts interacting with the application and is typically terminated when the user logs out or after a period of inactivity. It is a way to preserve certain data/state across multiple requests between the client and the server in an inherently stateless environment, which is the HTTP protocol.
These are the main components of a session:
So a session lifecycle starts with creation, when a session is created from a user login or starts interacting with an application. This is when a unique session ID is generated and associated with the user. The session is maintained through the session ID that is transmitted with each subsequent request from the client and is used to retrieve and manage session data on the server. The session ends when the user logs out or after a period of inactivity (session timeout). Any data stored in the session is usually deleted or invalidated.
Session management is pivotal for the seamless functioning and robust security of web applications. When it's not implemented effectively, applications become vulnerable to a host of security issues and often provide an experience that's frustrating for users. Thus, grasping and applying solid session management strategies are absolutely critical if one wants to build web applications that are secure, scalable, and user-friendly.
Session management acts as the security guard of web applications. It works to correctly identify and authenticate users, ensuring they only access what they’re allowed to. It’s there to protect sensitive information belonging to the users by allowing only authenticated and authorized individuals to access it. It also safeguards session identifiers as they are transmitted and stored. It shields applications from security threats, such as session hijacking, session fixation, and Cross-Site Request Forgery (CSRF).
From a user experience standpoint, sessions empower applications to remember user preferences and deliver personalized content, crafting an experience that is more engaging for the user. Efficient session management allows users to move between different devices and browser tabs when interacting with applications without needing to keep logging in. This ease of access enhances overall user convenience and experience. Sessions are like invisible assistants, remembering temporary data between user requests, meaning users can roam freely within an application without the fear of losing their progress or context.
Beyond security and user experience, sessions are a tool for collecting valuable data regarding user behavior and preferences. This kind of information is a goldmine for businesses, helping them make well-informed decisions and refine their strategies based on user interaction and needs. In essence, it’s like having a pulse on user behavior, enabling the refinement of business strategies and decision-making.
We’re going to produce a simple two page site that allows us access to a protected page if we are logged in. Fundamentally, this is an authentication setup, but we are going to set it up using JSON Web Tokens (JWT) that we’ll store on the client. This will give the user a live "session," so once they have logged in, they can continue to access the protected page, until the token and session expires.
We’re going to use Next.js 13 and the App Router. Let’s first create a new Next project:
npx create-next-app@latest
To follow this tutorial you should use the defaults from the prompts. We’ll then open up the code in our IDE. We’re using VS Code, so we can:
cd my-appcode .
We also want to install the libraries we’re going to use. None of these are authentication libraries. Instead they are libraries that let us directly access cookies and databases, and implement JWTs.
npm install js-cookie sqlite sqlite3 jsonwebtoken
js-cookie
is a lightweight JavaScript library that provides a straightforward API to handle browser cookies, allowing you to create, read, and delete cookies in a way that works with various JavaScript environments, like the browser and Node.js.sqlite
is a library that serves as a lightweight, file-based database engine, allowing developers to utilize SQL-based database functionality without the need for a full-fledged database management system.sqlite3
is a Node.js library that provides bindings to SQLite3, enabling interaction with SQLite databases, allowing developers to perform operations like querying, updating, and deleting records in SQLite databases from within Node.js applications.jsonwebtoken
is a Node.js library that allows you to securely handle JSON Web Tokens (JWTs), which are compact, URL-safe means of representing claims to be transferred between two parties, commonly used for authentication and information exchange in web development.Now, we’re ready to code.
First, let’s remove the boilerplate from the app/page.js file and replace it with this code:
"use client";import { useState } from "react";import { useRouter } from "next/navigation";function LoginPage() {const [username, setUsername] = useState("");const [password, setPassword] = useState("");const router = useRouter();const handleLogin = async (e) => {e.preventDefault(); // Prevent default form submissiontry {const response = await fetch("/api/login", {method: "POST",headers: {"Content-Type": "application/json",},body: JSON.stringify({username,password,}),});if (!response.ok) throw new Error("Login failed");const { token } = await response.json();document.cookie = `token=${token}; path=/`;router.push("/protected");} catch (error) {console.error(error);}};return (<div><form onSubmit={handleLogin}><label>Username:<inputtype="text"value={username}onChange={(e) => setUsername(e.target.value)}required/></label><br /><label>Password:<inputtype="password"value={password}onChange={(e) => setPassword(e.target.value)}required/></label><br /><button type="submit">Log In</button></form></div>);}export default LoginPage;
This is going to be our login page. The UX is simple–just a form with a username field, a password field, and a submit button.
When the button is clicked, the handleLogin
function is called.
The handleLogin
function is an asynchronous event handler that deals with the logic of the login attempt. There are five main components to this function:
const response = await fetch("/api/login", {...})
. This sends a POST HTTP request to the /api/login
endpoint with the username and password as the body of the request.if (!response.ok) throw new Error("Login failed")
. This checks if the response received from the server is not ok (i.e., the HTTP status code is not in the range 200-299). If it's not ok, it throws an error with the message "Login failed".const { token } = await response.json()
. If the response is ok, this parses the JSON body of the response and extracts the token property from it. This is the token that authenticates the user for subsequent requests.document.cookie = token=${token}; path=/
. This sets a cookie in the user's browser with the name token and the value received from the login API, which is accessible to any path in the domain.router.push("/protected")
. This navigates the user to the /protected
route of the app.So this is calling the login API, and if it receives a token back, sets that token in the browser and passes the user to the protected page. If the login fails and no token comes back, it just tells the user “Login failed.”
Let’s take a look at the login API next:
import { NextResponse } from "next/server";import sqlite3 from "sqlite3";import { open } from "sqlite";import jwt from "jsonwebtoken";async function authenticateUser(username, password) {let db = null;// Check if the database instance has been initializedif (!db) {// If the database instance is not initialized, open the database connectiondb = await open({filename: "userdatabase.db", // Specify the database file pathdriver: sqlite3.Database, // Specify the database driver (sqlite3 in this case)});}const sql = `SELECT * FROM users WHERE username = ? AND password = ?`;const user = await db.get(sql, username, password);return user;}export async function POST(req) {const body = await req.json();const { username, password } = body;// Perform user authentication here against your database or authentication serviceconst user = await authenticateUser(username, password);const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {expiresIn: "1m",});return NextResponse.json({ token });}
Let’s go through the POST function first. This is the part of the code that receives the POST call from the login page and authenticates our users.
const body = await req.json()
reads the JSON body from the incoming request object (req). It is awaited because reading the body is an asynchronous operation.const { username, password } = body
. After the body is read, the username and password are destructured from it. These would be the username and password sent in the request, likely provided by the user through a form in the frontend.const user = await authenticateUser(username, password)
calls the authenticateUser function to authenticate users against the user database.const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: "1m" })
. If the user is successfully authenticated, a JWT is generated using the jwt.sign method. This token includes the user’s ID (user.id) as part of its payload. The token is signed using a secret key stored in process.env.JWT_SECRET
, and it’s set to expire in 1 minute (expiresIn: "1m").return NextResponse.json({ token })
. Finally, if everything is successful, the function returns a response with the generated token in JSON format.The core parts here are the authenticateUser
call and the JWT signing. We’ll look closer at the authenticateUser
call in a moment, but let’s discuss JWTs first as they are integral to session management.
JSON Web Tokens (JWTs) are a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of the token, which is then signed to secure the information.
JWTs are commonly used for authentication and information exchange in web development. When a user logs in, the server generates a JWT that encodes user information (like user ID) and sends this token to the client. The client then includes this token in the Authorization header in subsequent requests, allowing the server to identify and authorize the user.
JWTs consist of three parts separated by dots (.):
A JWT might look like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
So here, the payload is our user ID, and we’re signing the token with our JWT_SECRET
in our .env.local
. This secret should be a long, random string. You can generate a random JWT secret like this:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
The authenticateUser
function is an asynchronous function intended to authenticate a user based on a provided username and password against a user record in an SQLite database.
Session management is really user management. So, you need to have a database set up of all your users so you can get, in this case, their IDs to add as the JWT payload. Here, we’ve set up a small SQLite database locally with a single user. To do this properly, you’ll need a full database for all your users, plus a way to add those users.
In this function, we initialize a connection to an SQLite database file named userdatabase.db
using the sqlite open
method. Once the database connection is established, we create an SQL query string, sql
, to select a user from the users table where the username
and password
match the provided arguments.
We then execute this SQL query using await db.get(sql, username, password)
, which will return the first row that satisfies the conditions (i.e., where the username and password match the input), or return undefined if no such row exists.
We then return that user to the POST
function, which uses the ID within the JWT payload, and returns that to the client.
After we’ve got the user and added the token to the user’s browser cookies, we are routed to the /protected page.
"use client";import Cookies from "js-cookie";import jwt from "jsonwebtoken";import { useEffect } from "react";import { useRouter } from "next/navigation";function ProtectedPage() {const router = useRouter();useEffect(() => {const token = Cookies.get("token");if (!token) {router.replace("/"); // If no token is found, redirect to login pagereturn;}// Validate the token by making an API callconst validateToken = async () => {try {const res = await fetch("/api/protected", {headers: {Authorization: `Bearer ${token}`,},});if (!res.ok) throw new Error("Token validation failed");} catch (error) {console.error(error);router.replace("/"); // Redirect to login if token validation fails}};validateToken();}, [router]);return <div>Protected Content</div>;}export default ProtectedPage;
Not too much is happening on this page, apart from checking the token exists on the local client and then sending it as part of the authorization header as a bearer token to the protected API endpoint. That endpoint is where we do two things:
import jwt from "jsonwebtoken";import { NextResponse } from "next/server";import { headers } from "next/headers";export async function GET() {try {const headersInstance = headers();const authHeader = headersInstance.get("authorization");const token = authHeader.split(" ")[1];const decoded = jwt.verify(token, process.env.JWT_SECRET);if (!decoded) {return NextResponse.json({ message: "Expired" },{status: 400,});} else if (decoded.exp < Math.floor(Date.now() / 1000)) {return NextResponse.json({ message: "Expired" },{status: 400,});} else {// If the token is valid, return some protected data.return NextResponse.json({ data: "Protected data" },{status: 200,});}} catch (error) {console.error("Token verification failed", error);return NextResponse.json({ message: "Unauthorized" },{status: 400,});}}
The first part is to extract the authorization header from the request, and then extract the token by splitting it from Bearer
.
Then we use the verify method on the JWT with the secret we used to sign it originally. This will allow us to show it is an authenticated token. If the token is invalid, jwt.verify
throws an error. From there, we want to check whether the token has expired by comparing the exp field in the decoded token with the current timestamp.
If the token is valid and not expired, the function returns a JSON response (with some data if you wanted). If the token is invalid or expired, or if any error occurs during verification, it returns a JSON response with a 400 status code and a message "Unauthorized".
If all is good, the user will be directed to the protected page:
Because the token persists within the browser, they can also go to the protected page in another tab:
But the token expires after only a minute. If they try and reload after that time, they are redirected to the login page again:
And you have successfully managed a session!
This is the most basic code to get session management working. But even here we’ve had to manage our own user database, manage our own secrets, and manage all of the logic in between. It isn’t a robust system:
If you are building your own session management system, you are also building your own user management system, and becoming a database administrator.
The easier way is to use a specifically designed authentication library. Here, we’re going to use Clerk, but any auth library is going to take this headache away from you.
First, we’ll set up the project in exactly the same way:
npx create-next-app@latest
Again, use the defaults from the prompts. Then open the code
cd my-clerk-appcode .
We don’t need to install all the libraries from before. All we need this time is the Clerk SDK:
npm install @clerk/nextjs
We’ll start with adding the environment variables we need for Clerk–our NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
and CLERK_SECRET_KEY
–into our .env.local file
:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_somethingCLERK_SECRET_KEY=sk_test_something
After that, we need to add the <ClerkProvider /> wrapper to the app. This is the critical component for session management. It is what we provide the active session and user information to all of Clerk’s components anywhere in the app. We add it in Layout.js
, wrapping the entire body of our application:
import { ClerkProvider } from "@clerk/nextjs";export const metadata = {title: "Create Next App",description: "Generated by create next app",};export default function RootLayout({ children }) {return (<ClerkProvider><html lang="en"><body>{children}</body></html></ClerkProvider>);}
We’ll then add some middleware. This is what decides which pages are protected and which aren’t. The default code below protects every page on the site:
import { authMiddleware } from "@clerk/nextjs";// This example protects all routes including api/trpc routes// Please edit this to allow other routes to be public as needed.// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your middlewareexport default authMiddleware({});export const config = {matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],};
We then need three pages. First, a sign up page, which will go at app/sign-up/[[...sign-up]]/page
:
import { SignUp } from "@clerk/nextjs";export default function Page() {return <SignUp />;}
Then a sign in page at app/sign-in/[[...sign-in]]/page
:
import { SignIn } from "@clerk/nextjs";export default function Page() {return <SignIn />;}
Finally, we’ll add a button to interact with these pages on our home page:
import { UserButton } from "@clerk/nextjs";export default function Home() {return (<div><UserButton afterSignOutUrl="/" /></div>);}
That’s a lot less code. We need to add paths to these in our .env.local
as well:
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-inNEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-upNEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
And that’s it. Run npm run dev
and you’ll get a way to sign in:
Sign in, and you’ll be at a protected account page:
And that is all that’s needed for session management with Clerk. You can set up other session options like token timeouts and custom payloads in your dashboard.
Session management is a critical component in web development, acting as the gatekeeper to user-specific, sensitive information and functionalities.
But implementing robust session management is not without its challenges. Security, performance, and usability issues are all concerns you’ll have to deal with when building session management in Next.js. Like many aspects of authentication, session management is one that is best left to dedicated libraries and solutions. Check out the Clerk session docs to find out more about setting this up easily with Clerk.
Start completely free for up to 10,000 monthly active users and up to 100 monthly active orgs. No credit card required.
Learn more about our transparent per-user costs to estimate how much your company could save by implementing Clerk.
The latest news and updates from Clerk, sent to your inbox.