Token authentication is a popular strategy used in safeguarding web and mobile applications from unauthorized access. In Next.js, you can utilize the authentication features provided by Next-auth.
Alternatively, you can opt to develop a custom token-based authentication system using JSON Web Tokens (JWTs). By doing so, you ensure that you have more control over the authentication logic; essentially, customizing the system to precisely match the requirements of your project.
Set Up a Next.js Project
To get started, install Next.js by running the command below on your terminal.
npx create-next-app@latest next-auth-jwt --experimental-app
This guide will utilize Next.js 13 which includes the app directory.
Next, install these dependencies in your project using npm, the Node Package Manager.
npm install jose universal-cookie
Jose is a JavaScript module that provides a set of utilities for working with JSON Web Tokens while the universal-cookie dependency provides a simple way to work with browser cookies in both the client-side and server-side environments.
Create the Login Form User Interface
Open the src/app directory, create a new folder, and name it login. Inside this folder, add a new page.js file and include the code below.
"use client";
import { useRouter } from "next/navigation";export default function LoginPage() {
return (
<form onSubmit={handleSubmit}>
<label>
Username:
<input type="text" name="username" />
</label>
<label>
Password:
<input type="password" name="password" />
</label>
<button type="submit">Login</button>
</form>
);
}
The code above creates a login page functional component that will render a simple login form on the browser to allow users to enter a username and a password.
The use client statement in the code ensures that a boundary is declared between server-only and client-only code in the app directory.
In this case, it’s used to declare that the code in the login page, particularly, the handleSubmit function is only executed on the client; otherwise, Next.js will throw an error.
Now, let’s define the code for the handleSubmit function. Inside the functional component, add the following code.
const router = useRouter();const handleSubmit = async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const username = formData.get("username");
const password = formData.get("password");
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
const { success } = await res.json();
if (success) {
router.push("/protected");
router.refresh();
} else {
alert("Login failed");
}
};
To manage the login authentication logic, this function captures the user credentials from the login form. It then sends a POST request to an API endpoint passing along the user details for verification.
If the credentials are valid, indicating the login process was successful—the API returns a success status in the response. The handler function will then use Next.js’ router to navigate the user to a specified URL, in this case, the protected route.
Define the Login API Endpoint
Inside the src/app directory, create a new folder and name it api. Inside this folder, add a new login/route.js file and include the code below.
import { SignJWT } from "jose";
import { NextResponse } from "next/server";
import { getJwtSecretKey } from "@/libs/auth";export async function POST(request) {
const body = await request.json();
if (body.username === "admin" && body.password === "admin") {
const token = await new SignJWT({
username: body.username,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("30s")
.sign(getJwtSecretKey());
const response = NextResponse.json(
{ success: true },
{ status: 200, headers: { "content-type": "application/json" } }
);
response.cookies.set({
name: "token",
value: token,
path: "https://www.makeuseof.com/",
});
return response;
}
return NextResponse.json({ success: false });
}
The primary task for this API is to verify the login credentials passed in the POST requests using mock data.
Upon successful verification, it generates an encrypted JWT token associated with the authenticated user details. Finally, it sends a successful response to the client, including the token in the response cookies; otherwise, it returns a failure status response.
Implement Token Verification Logic
The initial step in token authentication is generating the token after a successful login process. The next step is to implement the logic for token verification.
Essentially, you will use the jwtVerify function provided by the Jose module to verify the JWT tokens passed with subsequent HTTP requests.
In the src directory, create a new libs/auth.js file and include the code below.
import { jwtVerify } from "jose";export function getJwtSecretKey() {
const secret = process.env.NEXT_PUBLIC_JWT_SECRET_KEY;
if (!secret) {
throw new Error("JWT Secret key is not matched");
}
return new TextEncoder().encode(secret);
}
export async function verifyJwtToken(token) {
try {
const { payload } = await jwtVerify(token, getJwtSecretKey());
return payload;
} catch (error) {
return null;
}
}
The secret key is used in signing and verifying the tokens. By comparing the decoded token signature to the expected signature, the server can effectively verify that the provided token is valid, and ultimately, authorize the users’ requests.
Create .env file in the root directory and add a unique secret key as follows:
NEXT_PUBLIC_JWT_SECRET_KEY=your_secret_key
Create a Protected Route
Now, you need to create a route that only authenticated users can gain access to. To do so, create a new protected/page.js file in the src/app directory. Inside this file, add the following code.
export default function ProtectedPage() {
return <h1>Very protected page</h1>;
}
Create a Hook to Manage the Authentication State
Create a new folder in the src directory and name it hooks. Inside this folder add a new useAuth/index.js file and include the code below.
"use client" ;
import React from "react";
import Cookies from "universal-cookie";
import { verifyJwtToken } from "@/libs/auth";export function useAuth() {
const [auth, setAuth] = React.useState(null);
const getVerifiedtoken = async () => {
const cookies = new Cookies();
const token = cookies.get("token") ?? null;
const verifiedToken = await verifyJwtToken(token);
setAuth(verifiedToken);
};
React.useEffect(() => {
getVerifiedtoken();
}, []);
return auth;
}
This hook manages the authentication state on the client side. It fetches and verifies the validity of the JWT token present in cookies using the verifyJwtToken function, and then sets the authenticated user details to the auth state.
By doing so, it allows other components to access and utilize the authenticated user’s information. This is essential for scenarios like making UI updates based on authentication status, making subsequent API requests, or rendering different content based on user roles.
In this case, you’ll use the hook to render different content on the home route based on the authentication state of a user.
An alternative approach you might consider is handling state management using Redux Toolkit or employing a state management tool like Jotai. This approach guarantees components can get global access to the authentication state or any other defined state.
Go ahead and open the app/page.js file, delete the boilerplate Next.js code, and add the following code.
"use client" ;import { useAuth } from "@/hooks/useAuth";
import Link from "next/link";
export default function Home() {
const auth = useAuth();
return <>
<h1>Public Home Page</h1>
<header>
<nav>
{auth ? (
<p>logged in</p>
) : (
<Link href="/login">Login</Link>
)}
</nav>
</header>
</>
}
The code above utilizes the useAuth hook to manage the authentication state. In doing so, it conditionally renders a public home page with a link to the login page route when the user is not authenticated, and displays a paragraph for an authenticated user.
Add a Middleware to Enforce Authorized Access to Protected Routes
In the src directory, create a new middleware.js file, and add the code below.
import { NextResponse } from "next/server";
import { verifyJwtToken } from "@/libs/auth";const AUTH_PAGES = ["/login"];
const isAuthPages = (url) => AUTH_PAGES.some((page) => page.startsWith(url));
export async function middleware(request) {
const { url, nextUrl, cookies } = request;
const { value: token } = cookies.get("token") ?? { value: null };
const hasVerifiedToken = token && (await verifyJwtToken(token));
const isAuthPageRequested = isAuthPages(nextUrl.pathname);
if (isAuthPageRequested) {
if (!hasVerifiedToken) {
const response = NextResponse.next();
response.cookies.delete("token");
return response;
}
const response = NextResponse.redirect(new URL(`/`, url));
return response;
}
if (!hasVerifiedToken) {
const searchParams = new URLSearchParams(nextUrl.searchParams);
searchParams.set("next", nextUrl.pathname);
const response = NextResponse.redirect(
new URL(`/login?${searchParams}`, url)
);
response.cookies.delete("token");
return response;
}
return NextResponse.next();
}
export const config = { matcher: ["/login", "/protected/:path*"] };
This middleware code acts as a guard. It checks to ensure that when users want to access protected pages, they are authenticated and authorized to access the routes, in addition to, redirecting unauthorized users to the login page.
Securing Next.js Applications
Token authentication is an effective security mechanism. However, it’s not the only strategy available to safeguard your applications from unauthorized access.
To fortify applications against the dynamic cybersecurity landscape, it’s important to adopt a comprehensive security approach that holistically addresses potential security loopholes and vulnerabilities to guarantee thorough protection.