Role-based authorization using NextAuth and Next.js server actions

In this article, we'll explore how to implement authentication using NextAuth's Google and Credentials providers, use NextAuth's callback functions to implement role-based authorization, configure NextAuth in the latest App Router, access sessions in server and client components, and sign in using Google and Credentials providers.

This is a step-by-step guide if you're looking to implement these features in a Next.js project using the App Router and server actions (non-API approach). Let's get started!

Install packages and create environment variables

First, install next-auth and bcrypt as dependencies and @types/bcrypt as a dev dependency in your project. Next, open your terminal and run openssl rand -base64 32 to create a random string. Copy the string and create an environment variable named NextAuth_SECRET in your project. After that, log in to your Google Developers Console, get the client ID and secret, and create two more environment variables named GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your project.

Extend TypeScript modules

As we are using TypeScript, we need to extend the next-auth and next-auth/jwt modules to include the user's role type in the interfaces. To do so, add the following code in your types.ts file.

import { DefaultJWT } from 'next-auth/jwt';
import { DefaultSession, DefaultUser } from 'next-auth';

declare module 'next-auth' {
  interface Session extends DefaultSession {
    user: {
      role: string;
    } & DefaultSession['user'];
  }

  interface User extends DefaultUser {
    role: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT extends DefaultJWT {
    role: string;
  }
}

Create auth options

Most of the NextAuth magic happens in the authOptions object. Within the lib directory, create an auth.ts file with the following code:

Note: We're using Prisma ORM with MongoDB in this implementation. You may need to adjust the database query part depending on the ORM and database you're using.

import bcrypt from 'bcrypt';
import { db } from '@server/config/db';
import { NextAuthOptions } from 'next-auth';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';

export const authOptions: NextAuthOptions = {
  secret: process.env.NextAuth_SECRET,
  session: {
    strategy: 'jwt',
  },
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    }),
    Credentials({
      name: 'Credentials',
      credentials: {
        email: {
          label: 'Email',
          type: 'email',
          placeholder: 'john@example.com',
        },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials || !credentials.email || !credentials.password)
          throw new Error('Email or password is missing');

        try {
          const user = await db.user.findUnique({
            where: { email: credentials.email },
          });
          if (!user || !user.hashedPassword) {
            console.log('Invalid credentials');
            throw new Error('Invalid credentials');
          }

          const isCorrectPassword = await bcrypt.compare(
            credentials.password,
            user.hashedPassword
          );
          if (!isCorrectPassword) {
            console.log('Invalid credentials');
            throw new Error('Invalid credentials');
          }

          return {
            id: user.id,
            role: user.role,
          };
        } catch (err) {
          console.log(err);
          throw err;
        }
      },
    }),
  ],
  callbacks: {
    async signIn({ profile }) {
      if (profile && profile.name && profile.email) {
        const { name, email } = profile;
        await db.user.upsert({
          where: { email },
          update: { name, email },
          create: { name, email, role: 'USER' },
        });
      }
      return true;
    },
    async jwt({ token, user }) {
      if (user) token.role = user.role || 'USER';
      return token;
    },
    async session({ session, token }) {
      if (session.user) session.user.role = token.role;
      return session;
    },
  },
};

In the above code, we export the authOptions object. Within the object, we add the secret and define the session strategy as jwt.

Next, we add the providers array. In this array, first, we set up the Google provider. After that, we set up the Credentials provider with the authorize function.

The authorize function runs on Credentials sign-in. Within the function, we first ensure that the email and password are provided. After that, we start a try...catch block to perform some asynchronous operations. Within the block, we query the database to ensure that a user exists in the database with the provided email address. Next, we compare the passwords using the bcrypt.compare method to ensure that the provided password is correct. Finally, we return a user object with the id and role properties.

Next, we have a callbacks object with three callback functions: signIn, jwt, and session.

The signIn callback runs on every sign-in. We utilize this feature to get the user's details and save them in our database on Google sign-in. To do so, in the function, we get the profile object by destructuring the function parameter. Within the function, we ensure that the profile object itself and the name and email properties in the profile object exist. After that, we destructure the profile object and get the name and email out of it. Finally, we use the upsert method to update the user in the database if they exist; otherwise, we create a new user and return true to allow the user to sign in.

The jwt callback runs when a user signs in, the JWT token is accessed, and the session is refreshed. We utilize these features to get the user's role to the token object. To do so, in the function, we get the token and user objects by destructuring the function parameter. Within the function, we ensure that the user exists and update the role property of the token object with the user's role if the role exists; otherwise, we update the role property of the token object with 'USER' and return the updated token.

The session callback runs when a session is created on sign-in and whenever it is accessed in the application. We utilize these features to get the user's role to the session.user object. To do so, in the function, we get the session and token objects by destructuring the function parameter. Within the function, we ensure that the user object exists in the session object and update the role property of the session.user object with data from the token object and return the updated session.

Hopefully, you now see the purpose of each callback and how we use them to meet our application's requirements: to save the user's data in our database on Google sign-in and get the user's role to the session.user object to implement role-based authorization.

Create NextAuth API route

Now, let's create the API route for NextAuth. To do so, within the app directory, create an api directory. Within the api directory, create an auth directory. Within the auth directory, create the [...nextauth] directory. Within the [...nextauth] directory, create a route.ts file with the following code:

import NextAuth from 'next-auth';
import { authOptions } from '@lib/auth';

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

In the above code, we create a handler variable by calling the NextAuth function with the authOptions object. This is a request handler that will process authentication-related requests (e.g., sign-in, sign-out, etc.).

Create auth context

Now, to make the auth available throughout the application, we need an auth context. So, create a contexts directory at the root of your project. Within the contexts directory, create an Auth.tsx file with the following code:

'use client';

import { SessionProvider } from 'next-auth/react';

export const AuthProvider = SessionProvider;

In the above code, we create and export the AuthProvider variable by assigning the SessionProvider to it. Good to know that, as we are using Next.js App Router, we need to export the AuthProvider from a client component.

Add auth provider to the root layout

Now, navigate to the root layout.tsx file and add the AuthProvider like the following code:

import { ReactNode } from 'react';
import { AuthProvider } from '@contexts/Auth';
import { authOptions } from '@lib/auth';
import { getServerSession } from 'next-auth';

export default async function RootLayout({
  children,
}: {
  children: ReactNode;
}) {
  const session = await getServerSession(authOptions);

  return (
    <html lang='en'>
      <body>
        <AuthProvider session={session}>{children}</AuthProvider>
      </body>
    </html>
  );
}

In the above code, we use the getServerSession function provided by the next-auth package and pass the authOptions to it to get the session. After that, we wrap the children with the AuthProvider and pass the session as a prop to it.

Access session in a server component

Following is an example of how we can access the session in a server component:

import { getServerSession } from 'next-auth';
import { authOptions } from '@lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await getServerSession(authOptions);
  if (!session) redirect('/sign-in');
  if (session.user.role !== 'ADMIN') redirect('/');

  return <main>Dashboard</main>;
}

In the above code, we have a dashboard page that only an admin can access. As this is a server component, we use the getServerSession function and pass the authOptions to it to get the session. If the session doesn't exist, we redirect the user to the sign-in page. If the session exists but the user's role isn't 'ADMIN', we redirect the user to the home page. Otherwise, if the session exists and the user's role is 'ADMIN', we allow the user access to the dashboard page.

Access session in a client component

Following is an example of how we can access the session in a client component:

'use client';

import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';

export default function SignInPage() {
  const router = useRouter();
  const { data: session } = useSession();

  if (session) {
    if (session.user.role === 'USER') router.push('/');
    if (session.user.role === 'ADMIN') router.push('/dashboard');
  }

  return (
    <main>
      <SignInForm />
    </main>
  );
}

In the above code, we have a sign-in page that we don't want a signed-in user to access. As this is a client component, we use the useSession hook to access the session. If the session exists and the user's role is 'USER', we push the user to the home page; otherwise, if the user's role is 'ADMIN', we push the user to the dashboard page.

Sign in with the providers

Let's now take a look at how we can build the sign-in functionalities using the NextAuth Google and Credentials providers.

'use client';

import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';

export default function SignInForm() {
  const router = useRouter();

  async function credentialSignIn(formData: FormData) {
    const email = formData.get('email');
    const password = formData.get('password');

    const response = await signIn('credentials', {
      email,
      password,
      redirect: false,
    });
    if (response?.error) return console.log(response.error);
    router.push('/');
  }

  async function googleSignIn() {
    signIn('google', { redirect: false, callbackUrl: '/' });
  }

  return (
    <section>
      <form action={credentialSignIn}>
        <input type='email' name='email' placeholder='Email address' />
        <input type='password' name='password' placeholder='Password' />
        <input type='submit' />
      </form>
      <button onClick={googleSignIn}>Google Sign In</button>
    </section>
  );
}

In the above code, we have a SignInForm component. It's a client component as the NextAuth signIn function can only run in a client component. Within the component, we have two functions: credentialSignIn and googleSignIn.

Within the credentialSignIn function, we get the email and password from the formData. After that, we use the signIn function and pass the provider (credentials in this case) as the first argument and an object containing the data and options as the second. Finally, we check for errors and push the user to the home page upon successful sign-in.

The googleSignIn function is straightforward. Within the function, we use the signIn function and pass google as the first argument and the options object as the second.

That's it! With this, it's a wrap on how you can use Next.js as a full-stack framework with server actions and NextAuth to implement authentication and role-based authorization. I hope this helps you understand NextAuth a little better and take advantage of the tool in your next project. Cheers!

Share this article with your friends

Copy URL

Elevate your JavaScript and freelance journey

Supercharge your JavaScript skills and freelance career. Subscribe now for expert tips and insights!