ReactHustle

How to Setup Role-Based Authentication in NextJS + NextAuth.js

Jasser Mark Arioste

Jasser Mark Arioste

How to Setup Role-Based Authentication in NextJS + NextAuth.js

Hello, hustler! In this tutorial, you will learn the best practices for setting up role-based authentication/authorization on NextJS with NextAuth.js and Typescript.

There are many tutorials out there that check the role from the client-side / browser. In my experience, this leads to weird usability issues.

Since the addition of middleware in NextJS 12.2, we can now check for the user properties in the server. This makes it easier to grant or remove access for static pages since we don't have to check it from the client-side.

You'll learn how to set up a middleware that checks for the user role. Once the role is checked, you can use redirect or rewrite to show the appropriate page to the user. 

Ready? Let's start!

The Problem #

We'll have an /admin route that only allows access to admin users. If the user is not logged in it should redirect to the /login route. If the user logged in but is not an admin, it should show the /403 page.

Step 0: Project Setup #

First, let's create a new NextJS Project (with Typescript) by running the command:

npx create-next-app --ts role-based-auth
1

Once everything is installed, let's start a local server at http://localhost:3000 by running the command:

cd role-based-auth && yarn dev
1

Modifying tsconfig.json

Add the following setting to your tsconfig.json to enable absolute imports for a cleaner code.

{
  // ...other options
  "baseUrl": "."
}
1234

Setting up the Users Data and Services

To shorten this tutorial, we'll be using a local file to act as our database. Create a file data/users.json and copy-paste the following contents.

[
  {
    "email": "admin@example.com",
    "password": "@Password123",
    "name": "Test Admin",
    "role": "admin",
    "id": "001"
  },
  {
    "email": "user@example.com",
    "password": "@Password123",
    "name": "Test User",
    "role": "user",
    "id": "002"
  }
]
12345678910111213141516

These are two user accounts that we'll be using for login. Notice that they have role properties of user and admin.

Next, let's create an interface that allows us to access our file database. First, we'll create the interface. Create a file services/IUserService.ts:

// services/IUserService.ts
import { User } from "next-auth";
export interface IUserService {
  signInCredentials(email: string, password: string): Promise<User> | User;
}
12345

Next, let's create the concrete class that implements the IUserService interface. Create a file services/UserService.ts:

// services/UserService.ts
import users from "data/users.json";
import { User } from "next-auth";
import { IUserService } from "./IUserService";
export class InMemoryUserService implements IUserService {
  signInCredentials(email: string, password: string): User | Promise<User> {
    const user = users.find((user) => {
      const emailFound = email === user.email;
      const isPasswordCorrect = password === user.password;
      const userFound = emailFound && isPasswordCorrect;
      return userFound;
    }) as User;
    if (!user) {
      throw new Error("Invalid email or password");
    }
    return user;
  }
}

export const userService = new InMemoryUserService();
1234567891011121314151617181920

Explanation:

Line 2: We import our file database to check later when logging in.

Line 6: We implement the function signInCredentials that accepts email and password and returns a User.

Line 7-12: We check our database if there's a User that matches the email and password and return it. If there's no user we throw an error.

Step 1: Augmenting the User Object Definition #

To access the role property of our user from anywhere in your application, you have to augment the Session and JWT interface from "next-auth" and "next-auth/jwt" packages. I go a bit more in-depth about this in my previous tutorial: How to Extend User and Session in NextAuth.js

Create a file nextauth.d.ts at the root directory of your project.

// nextauth.d.ts
import { DefaultSession, DefaultUser } from "next-auth";
// Define a role enum
export enum Role {
  user = "user",
  admin = "admin",
}
// common interface for JWT and Session
interface IUser extends DefaultUser {
  role?: Role;
}
declare module "next-auth" {
  interface User extends IUser {}
  interface Session {
    user?: User;
  }
}
declare module "next-auth/jwt" {
  interface JWT extends IUser {}
}
1234567891011121314151617181920

Step 2: Setting up next-auth handler #

Next, we'll set up the next-auth handler to add the role property to the session.user and token.

Create the file pages/api/auth/[...nextauth].ts. Let's add the imports and some guard that checks for the NEXTAUTH_SECRET environment variable:

import { NextApiRequest, NextApiResponse } from "next";
import CredentialsProvider from "next-auth/providers/credentials";
import NextAuth from "next-auth";
import { userService } from "services/UserService";
if (!process.env.NEXTAUTH_SECRET) {
  throw new Error("Please provide process.env.NEXTAUTH_SECRET");
}
1234567

NEXTAUTH_SECRET is an environment variable that next-auth automatically detects and uses for the secret configuration for the JWT. make sure you create an .env.development file and define the NEXTAUTH_SECRET variable there:

// .env.development
NEXTAUTH_SECRET=mysecret123
1

Next, let's add a CredentialsProvider:

...
export default NextAuth({
  providers: [
    CredentialsProvider({
      name: "Credentials",
      id: "credentials",
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials) {
          throw new Error("No credentials.");
        }
        const { email, password } = credentials;
        return userService.signInCredentials(email, password);
      },
    }),
  ],
})
1234567891011121314151617181920

We use our userService from earlier to check if there's a user that matches the credentials.

Next, let's add a custom sign-in page:

...
export default NextAuth({
  providers: [
     ...
  ],
  pages: {
    signIn: "/signin",
  },
})
123456789

Next, configure the jwt and session callbacks to set the role for the token and session.user.

...
export default NextAuth({
  providers: [
     ...
  ],
  ...
  callbacks: {
    async jwt({ token, user }) {
      /* Step 1: update the token based on the user object */
      if (user) {
        token.role = user.role;
      }
      return token;
    },
    session({ session, token }) {
      /* Step 2: update the session.user based on the token object */
      if (token && session.user) {
        session.user.role = token.role;
      }
      return session;
    },
  },
})
1234567891011121314151617181920212223

Notice that we can access the role property here without any error. This is due to augmenting the User definition from Step 1. 

Step 3: Creating the Admin Page #

First, I'd like to use tailwind and daisyUI for styling. This step is optional and you can skip this step or use another styling library. Let's install the dependencies and initialize a tailwind config:

yarn add -D tailwindcss postcss autoprefixer daisyui && npx tailwindcss init -p
1

Modify the tailwind.config.js:

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  daisyui: {
    themes: ["light"],
  },
  theme: {
    container: {
      center: true,
      padding: {
        DEFAULT: "1rem",
      },
    },
    extend: {},
  },
  plugins: [require("daisyui")],
};
12345678910111213141516171819202122

Add tailwind in styles/globals.css:

# styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
# you may replace everything with just these three lines.
12345

You're all set for the styling.

Next, let's create a simple AdminPage component and create the file pages/admin.tsx

// pages/admin.tsx
import Link from "next/link";
import React from "react";
import { signOut } from "next-auth/react";

const AdminPage = () => {
  return (
    <div className="container">
      <div className="grid place-content-center min-h-screen">
        <div className="flex flex-col gap-4">
          <h1 className="text-4xl">Admin Page</h1>

          <Link className="btn btn-primary" href="/">
            Go to Index Page
          </Link>
          <button
            className="btn btn-accent btn-outline"
            onClick={() => signOut()}
          >
            Sign Out
          </button>
        </div>
      </div>
    </div>
  );
};

export default AdminPage;
12345678910111213141516171819202122232425262728

This is all we need for the admin page. We don't have to check the access logic using react. In fact, I don't recommend this since it causes a slight delay. The access logic will be added later inside the middleware.

If you navigate to /admin, you should have something like this, any user can freely access it at this point:

Admin page

Step 5: Creating the 403 Page #

Next, let's create the /403 page. We'll show this page if a logged-in user is not authorized to view a protected page. Create the file pages/403.tsx:

// pages/403.tsx
import { NextPage } from "next";
import Link from "next/link";
import React from "react";
const Custom403: NextPage = () => {
  return (
    <div className="container">
      <div className="grid place-content-center min-h-screen">
        <div className="flex flex-col items-center">
          <div className="my-4 text-center">
            <h1 className="text-2xl">403 - Unauthorized</h1>
            <p className="">Please login as admin</p>
          </div>
          <Link className="btn btn-primary" href="/signin">
            Login
          </Link>
        </div>
      </div>
    </div>
  );
};

export default Custom403;

1234567891011121314151617181920212223

Navigate to /403 and you should have something like this:

403 Page

Step 4: Creating a Custom Sign-in Page #

Next, let's create a custom sign-in page. Create the file pages/signin.tsx

import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import React, { useState } from "react";

const SignInPage = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();
  const callbackUrl = decodeURI((router.query?.callbackUrl as string) ?? "/");
  const handleSubmit = async (
    e: React.FormEvent<HTMLFormElement>
  ): Promise<void> => {
    e.preventDefault();
    const result = await signIn("credentials", {
      email,
      password,
      callbackUrl: callbackUrl ?? "/",
      redirect: false,
    });
    if (result?.error) {
      setError(result.error);
    }
    if (result?.ok) {
      router.push(callbackUrl);
    }
  };
  return (
    <div className="container">
      <div className="flex items-center justify-center min-h-screen">
        <div className="flex flex-col items-center card shadow-md">
          <form className="card-body w-96" onSubmit={handleSubmit}>
            <h1 className="text-4xl my-8">Sign In</h1>
            {!!error && <p className="text-error">ERROR: {error}</p>}
            <input
              type="text"
              className="input input-bordered"
              placeholder="email"
              value={email}
              onChange={(e) => {
                setEmail(e.target.value);
              }}
            />
            <input
              type="password"
              className="input input-bordered"
              placeholder="password"
              value={password}
              onChange={(e) => {
                setPassword(e.target.value);
              }}
            />
            <button className="btn" type="submit">
              Sign In
            </button>
          </form>
        </div>
      </div>
    </div>
  );
};

export default SignInPage;
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263

After this step you should have a simple sign-in page like this:

Role Based Authentication NextJS - Login page

Step 6: Adding a middleware #

Next, let's add a middleware to handle access to our admin page or any other protected pages. Create the file middleware.ts in the root directory of your project. Note that this is a very simple middleware implementation, if you need to chain multiple middleware functions, take a look at my previous tutorial: How to Chain Multiple Middleware Functions in NextJS

// middleware.ts
import { getToken } from "next-auth/jwt";
import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest, _next: NextFetchEvent) {
  const { pathname } = request.nextUrl;
  const protectedPaths = ["/admin"];
  const matchesProtectedPath = protectedPaths.some((path) =>
    pathname.startsWith(path)
  );
  if (matchesProtectedPath) {
    const token = await getToken({ req: request });
    if (!token) {
      const url = new URL(`/signin`, request.url);
      url.searchParams.set("callbackUrl", encodeURI(request.url));
      return NextResponse.redirect(url);
    }
    if (token.role !== "admin") {
      const url = new URL(`/403`, request.url);
      return NextResponse.rewrite(url);
    }
  }
  return NextResponse.next();
}
1234567891011121314151617181920212223

I'm aware that next-auth has a middleware function, but I like full control over the code.

Explanation:

Line 5: We define the protected paths of our application.

Line 6-9: We check if the current path matches at least one of our protected paths.

Line 11: We use getToken to check if there's a logged-in user.

Line 12-16: If there is no token/user, we redirect the user to the sign-in page. 

Line 17-20: We check if the role and if the user is not an admin, we use NextResponse.rewrite to show the /403 page.

That's it! 

The full code is available on GitHub. And the demo is available on Vercel

For admin user, you can use these credentials: admin@example.com / @Password123

For regular user credentials: user@example.com / @Password123

Conclusion #

We learned how to implement a role-based authentication using NextJS. We used a middleware function to secure the protected pages of our application. We were able to keep our components clean, without any dependencies from other components.

If you like this tutorial, please leave a like or share this article. For future tutorials like this, please subscribe to our newsletter or follow me on Twitter.

Resources #

Credits: Image by Julius Silver from Pixabay

Share this post!

Related Posts

Disclaimer

This content may contain links to products, software and services. Please assume all such links are affiliate links which may result in my earning commissions and fees.
As an Amazon Associate, I earn from qualifying purchases. This means that whenever you buy a product on Amazon from a link on our site, we receive a small percentage of its price at no extra cost to you. This helps us continue to provide valuable content and reviews to you. Thank you for your support!
Donate to ReactHustle