Authentication is crucial in every app, and ensuring sufficient security is vital, whether you are creating a small app or a production-ready deployment. Understanding authentication concepts such as hashing and cryptography can be challenging, but fortunately, there are a few libraries that simplify the process.
For this blog, I will explain how I have implemented authentication in my app using JSON Web Token and server actions in NextJS. Since NextJS is a full-stack app, the concept of CORS will not be covered here.
To begin, we need to install the relevant libraries. This includes jose, and bcrypt.js. There are vital to encode and decode the user passwords stored in the database. I am using a PostgreSQL database with PRISMA ORM.
To get started, assuming you have all the necessary setup (NextJS app and database), run the following command:
npm i bcrypt jose
Jose is similar to jsonwebtoken but also works in a browser environment, enabling you to decode the token if you need the data in the frontend.
Next, we are going to create a JWT_SECRET that will be used to hash the passwords. You can set this to any random number or use OpenSSL to generate the token by running the command:
openssl rand -base64 32
Save the token in the .env file, without the .local prefix, to ensure it is not exposed in the browser environment.
NOTE: It’s crucial that you keep this key private and only accessible to the server. You can use an env provider or keep it in a secure location. Be sure you set it in Next.js without the NEXT_PUBLIC
prefix.
If you accidentally leak the key to the client, then an attacker could use your key to sign bogus credentials with whatever role they like and gain full access to your platform!
Next, we are going to tap into the power of middleware in NextJS. Middleware is the cornerstone of our authentication system, designed to protect routes that require authentication.
By implementing this, you ensure that only authenticated users can access certain parts of your application, safeguarding sensitive information and user privacy.
NextJS already includes a middleware.js file, but if it does not exist, create one in the root directory and name it middleware.js. Add the following code to the middleware.
// middleware.js
import { NextResponse, NextRequest } from "next/server";
import * as jose from "jose";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
const isProtectedPath =
path.startsWith("/profile") ||
path.startsWith("/admin") ||
const isPublicPath =
path.startsWith("/login") ||
path.startsWith("/register")
const token = request.cookies.get("token");
let userData = null;
if (token) {
try {
const { payload } = await jose.jwtVerify(token.value, JWT_SECRET);
userData = payload;
} catch (error) {
console.error("Invalid token:", error.message);
}
}
const isAdmin = userData?.role == "admin";
// redirect users to homepage if they are not admin
if (path.startsWith("/admin") && !isAdmin) {
return NextResponse.redirect(new URL("/", request.nextUrl));
}
if (path === "/admin" && isAdmin) {
return NextResponse.redirect(
new URL("/admin/dashboard?tab=0", request.nextUrl)
);
}
if (isProtectedPath && !userData) {
return NextResponse.redirect(
new URL("/login", request.nextUrl)
);
} else if (userData && isPublicPath) {
//prevent users from visiting login page if they are already logged in
return NextResponse.redirect(new URL("/", request.nextUrl));
}
}
export const config = {
matcher: [
"/",
"/login",
"/signup",
"/register",
"/profile",
"/profile/:path*",
"/admin",
"/admin/:path*",
],
};
Note, we are using jose instead of JWT since we need to read user data rather than just check if the token exists. If we just check if the token exists, a user can create a token with nothing and be authenticated.
The matcher object configures the path that you want the API to match. You can modify the protected and public routes, and make sure to include them in the matcher function.
Next, we are going to create a login functionality that utilizes PRISMA to find and authenticate the user and return an HTTP-only cookie in the response body. It is better to create this in an api route.
Here is the login function in a file called /api/auth/login:
import bcrypt from "bcryptjs";
import prisma from "@/prisma/prisma";
import { SignJWT } from "jose";
import { NextRequest, NextResponse } from "next/server";
// Create a secret key from the env string
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function POST(req: NextRequest, res: NextResponse) {
const { email, password } = await req.json();
try {
// Check if the user exists
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
deleted: false,
},
});
if (!user) {
return NextResponse.json(
{ error: "No user with matching email found" },
{ status: 404 }
);
}
// Compare password
const isPasswordValid = await bcrypt.compare(
password,
user.password_digest
);
if (!isPasswordValid) {
return NextResponse.json(
{ error: "Invalid password, please try again!" },
{ status: 401 }
);
}
// Generate JWT with jose
const token = await new SignJWT({
id: user.id,
email: user.email,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("8h")
.sign(secret);
// Return user details and token
const response = NextResponse.json(user, { status: 202 });
response.cookies.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return response;
} catch (error) {
return NextResponse.json(
{ error: "Internal server error" || error.message },
{ status: 500 }
);
}
}
You can add some more complicated functionalities, such as checking the request ip address to rate-limit the user. If you wish to add a rate limiter in the login function, you can get the user's IP address by doing the following:
const rateLimitMap = new Map();
//creates a map to store the requests
const ip = req.ip || req.headers.get("X-Forwarded-For");
const limit = 3; // Limiting requests to 3 login attempts per minute per IP
const windowMs = 60 * 1000; // 1 minute
const suspensionMs = 5 * 60 * 1000; // 5 minutes
// Function to limit requests to 3 login attempts per IP
const now = Date.now();
if (!rateLimitMap.has(ip)) {
rateLimitMap.set(ip, { count: 0, lastReset: now, suspendedUntil: null });
}
const ipData = rateLimitMap.get(ip);
// Check if the IP is currently suspended
if (ipData.suspendedUntil && now < ipData.suspendedUntil) {
return NextResponse.json(
{ error: "Too Many Requests. Try again after 5 minutes" },
{ status: 429 }
);
}
if (now - ipData.lastReset > windowMs) {
ipData.count = 0;
ipData.lastReset = now;
}
if (ipData.count >= limit) {
ipData.suspendedUntil = now + suspensionMs;
return NextResponse.json(
{ error: "Too Many Requests. Try again after 5 minutes" },
{ status: 429 }
);
}
ipData.count += 1;
I know this is a lot of if requests; I am guilty, but you can also streamline this code to make it more efficient. In the register route, you can use the same functionality in the login route to return the HTTP code.
Now we need to create a helper function that you can call to get the user. Remember, this function cannot return the user to the frontend; it will only work on the server.
// file path /lib/decodeToken
import { NextRequest } from "next/server";
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export const getDataFromToken = async (request: NextRequest) => {
try {
const token = request.cookies.get("token")?.value || "";
if (!token) {
throw new Error("No token found");
}
// Verify JWT with jose
const { payload } = await jwtVerify(token, secret);
return payload; // contains your user data (id, email, etc.)
} catch (error: any) {
throw new Error(error.message || "Invalid token");
}
};
This function enables you to decode the token, but does not account for scenarios where the token exists but has expired, and you might need to further modify it to handle such scenarios.
To get user data, create a route (/api/me) where you can fetch the user data each time there is a logged-in user. This route will receive the request cookies and use the decodeToken function to return user data:
import { getDataFromToken } from "@/lib/decodeToken";
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest, res: NextResponse) {
try {
const userData = await getDataFromToken(req);
let responseData = userData;
if (!userData || !userData) {
return NextResponse.json(
{ error: "Unauthorized request" },
{ status: 401 }
);
}
return NextResponse.json(userData, { status: 200 });
} catch (error) {
return new NextResponse(null, { status: 400 });
}
}
With the current setting, you can fetch user data and save it in context each time the user logs in. This ensures that components that need user data have access to the data.
Creating Logout Route
Creating a logout route is as easy as just deleting the token. Create a new route in /api/auth/logout and add this code:
import { NextResponse, NextRequest } from "next/server";
export async function GET(res: NextRequest) {
try {
const response = NextResponse.json({
message: "Logout Successful",
success: true,
});
response.cookies.delete("token");
return response;
} catch (error) {
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
Conclusion
We’ve covered the use of JWTs for efficient user authentication, storing user data in cookies for quick access, and the importance of custom middleware for route protection.
Remember, the journey to secure and efficient authentication is ongoing and ever-evolving. The strategies and tools we’ve discussed are at the forefront of current best practices, providing a strong foundation for your Next.js applications.
However, the world of web security is dynamic, so staying updated with the latest trends and updates is crucial. Adding the rate limiter is also necessary to protect the login route from brute attacks.