handling authentication in next js
user profile avatar
the don

Published on • 🕑6 min read

Handling Authentication with JWT in NextJs

3likes3

Blog views1.1K

Listen to this blog

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 auth concepts such as hashing, cryptography and so on could be challenging, but luckily there are 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 fullstack app, the concept of CORS will not be covered here. 

To begin we need to install the relevant libraries. This include jsonwebtoken, jose, and bcrypt.js. There are vital to encoded and decode user password stored in the database. I am using a postgress database with PRISMA orm.

To get started, assuming you have all the necessary setup (NextJS app and database), run the following command:

npm i jsonwebtoken bcrypt jose

Jose is similar to jsonwebtoken but also works in 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 .env file, without the .local prefix to ensure it is not exposed into the browser environment. 

NOTE: It’s crucial that you keep this key private and only accessible to the server. You can use a dot 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.

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 a 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 jwt from "jsonwebtoken";
import { NextRequest, NextResponse } from "next/server";

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,
      },
    });

    // Compare the password
    if (user) {
      const isPasswordValid = await bcrypt.compare(
        password,
        user.password_digest
      );
      if (!isPasswordValid) {
        return NextResponse.json(
          { error: "Invalid password, please try again!" },
          { status: 401 }
        );
      } else {
        // Generate a JWT token
        const token = jwt.sign(user, process.env.JWT_SECRET, {
          expiresIn: "8h",
        });
        // Return user details and token
        const response = NextResponse.json(user, { status: 202 });
        response.cookies.set("token", token, { httpOnly: true });
        return response;
      }
    } else {
      return NextResponse.json(
        { error: "No user with matching email found" },
        { status: 404 }
      );
    }
  } catch (error) {
    console.error(error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  } finally {
    await prisma.$disconnect();
  }
}

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 user 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 alot 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 in the frontend, it will only work in the server.

// file path /lib/decodeToken
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
export const getDataFromToken = (request: NextRequest) => {
  try {
    const token = request.cookies.get("token")?.value || "";
    const decodedToken: any = jwt.verify(token, process.env.JWT_SECRET);
    return decodedToken;
  } catch (error: any) {
    throw new Error(error.message);
  }
};

This function enables you to decode the token but does not account for scenarios where the token exists but is 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 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 attack.

 

Like what you see? Share with a Friend

3 Comments

3 Likes

Comments (3)

sort comments

Before you comment please read our community guidelines


Please Login or Register to comment

user profile avatar

tech girl

Published on

What would be the benefit of implementing your own auth flow instead of using nextauth or other auth providers such as oauth?

user profile avatar

Tech Wizard

online

Published on

I will consider removing JWT to reduce the total size of my node modules. Thanks @diamond degesh!❤

user profile avatar

diamond degesh

Published on

You do not need to install JWT and Jose, you can just use Jose, which is better because it's just JWT that works in both environments!