Authentication and Role-Based Access Control in Next.js

Human heart

Human Made

In today's modern web development, the need for a robust authentication mechanism has become a common requirement. Many websites and web applications of all sizes need secure access controls to safeguard sensitive information and ensure a tailored user experience. Even in scenarios where access restrictions are not really needed, authentication can be used as a mechanism to identify and personalize user interactions to enhance user experience and engagement.

Now, let’s say that for your project you decided to use Next.js, the powerful framework to build React.js applications. Out of the box, this framework does not provide a mechanism for authentication, but luckily, we have the excellent open-source project called Auth.js that fills that gap and provides a comprehensive authentication solution for JavaScript web applications.

Introducing NextAuth.js

NextAuth.js is an authentication solution for Next.js. This solution has evolved beyond its initial goal of providing authentication solely for Next.js and is currently undergoing a strategic rebranding from NextAuth.js to Auth.js. It is transitioning into an authentication library that can support a spectrum of JavaScript frameworks.

Auth.js now extends its reach beyond Next.js, aiming to become a go-to solution for various frameworks. As part of this expansion, it embraces compatibility with not only Next.js but also other frameworks like Sveltekit and SolidStart, with further integrations on the horizon, including Express, Remix, Astro, and Nuxt.

This article explores the capabilities of NextAuth.js version 4, its functionality, implementation, and how it could be used to create Role-Based Access Control on a Next.js application.

Authentication for an application with Next.js App Router

In this article, we will go through the process of implementing authentication with GitHub as the authentication provider on a Next.js application that utilizes the relatively new App Router. For this purpose, we will make use of Next.js Route Handlers to manage all the requests necessary for authenticating into our application.

The concepts and most of the steps in this article are applicable to any Next.js 13+ application you may already have. We also created a Git repository that can serve as a guide or playground. The repository utilizes Next.js 14, Tailwind CSS, the src directory, and the App Router feature.

To begin we must install the next-auth package. For these examples we will be using npm, but you could use Yarn or another package manager.

npm install next-auth

The next step is creating a file called route.js file inside a /src/app/api/auth/[...nextauth]/ directory. When you create this directory structure, be careful to not miss any of the brackets or dots, as they are needed for Next.js to be able generate the required routes dynamically.

From the root of the project you can use the following command

mkdir -p 'src/app/api/auth/[...nextauth]'

We will come back to this file, but first we have to decide which authentication methods we want to use.

Login Providers

NextAuth.js supports a variety of authentication methods. You can integrate it with a long list of built-in authentication providers that includes all the popular social networks like Google, Facebook, Twitter, and Instagram. It also works with other services like Auth0, Keycloak, and Cognito, among many others. If the provider you want to use is not part of the supported ones, there is a generic OAuth/OpenID that you can utilize to create a custom OAuth Provider.

Using an authenticator provider is the preferred method, but NextAuth.js also provides two alternative methods of authentication. An email authentication solution that allows users to authenticate via “magic links” sent to their email, and a credentials authentication that offers a way to integrate an existing authentication service with traditional usernames and passwords, two-factor authentication, or a hardware device.

In our Next.js application, we need to define some configuration used to initialize NextAuth.js. We will place the configuration in a separate file inside the src/app/api/auth/[...nextauth] directory. This approach allows us to keep things organized, as we will need to import this configuration in more than one place within the Next.js application.

I'm naming the file containing the configuration “options.ts”, but you can choose any name you prefer. Inside this file, we will export an options object that contains all the configuration needed for the different types of authentication supported by our application. There are many things that can be configured in this options object, but the only one that is really required is an array of providers, with one or more authentication providers to use in the application.

Now, let's see this in practice. On our options.ts file we first include the provider we want to use and create an options object with the configuration. In the example I will be using GitHub as it is one of the easiest to configure, but you can check the documentation from Auth.js to see the configuration requirements for other authentication providers.

import GithubProvider from 'next-auth/providers/github' export const authOptions = { providers: [ GithubProvider({ clientId: process.env.GITHUB_ID as string, clientSecret: process.env.GITHUB_SECRET as string, }), ], }

GitHub only requires two configuration values: a client ID and a client secret. To obtain these, you need to create a GitHub app first. You can create an app on your personal account by visiting https://github.com/settings/apps. It is also possible to create an app for one of your organizations. ​​

During the app creation, you'll need to give it a name, configure a homepage, and set a callback URL. For local development, use http://localhost:3000/api/auth/callback/github as the callback URL. After creating the app, you'll be provided with the client ID, and you can generate a client secret. Save both, as we'll use them shortly.

There is one more thing needed by the options object: a secret random string used to hash tokens, cookies, and keys. This secret is technically not required during development, as it defaults to the SHA hash of the options object. However, it is needed in production and will be set to the value of the NEXTAUTH_SECRET environmental variable. Therefore, you don't really have to configure it in the options.ts file.

Environment Variables

It is good practice not to include sensitive information as part of your application code and, instead, rely on environment variables. To store our authentication credentials for different providers during development, we will create a .env.local file. There is a rule in the .gitignore file to prevent this file from being committed to Git.

The .env.local will look something like this, but you will have to complete it with your credentials.

# Next Auth Config NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="" # GitHub Config GITHUB_ID="YOUR_GITHUB_CLIENT_ID" GITHUB_SECRET="YOUR_GITHUB_CLIENT_SECRET"

As we mentioned earlier, the NEXTAUTH_SECRET is not strictly necessary during development. However, I recommend configuring it from the beginning. This ensures a consistent key for hashing tokens and cookies, even when making changes to the application options. It also helps avoid the risk of forgetting to set it up later.

You can create a good value for the NEXTAUTH_SECRET using the following command:

openssl rand -base64 32

Defining Route Handlers for NextAuth.js

Our next step is to create all the necessary routes for Auth.js to handle authentication. Luckily thanks to Next.js Route Handlers this is very easy. We create a route.ts file inside the /src/app/api/auth/[...nextauth]/ directory, the same directory we previously created to store the options.ts file.

In this file we import NextAuth and the options object we previously defined. By initializing NextAuth with these options, we obtain a route handler. Finally, we export the handler as both GET and POST, as NextAuth will need to handle both types of requests to function properly.

import NextAuth from "next-auth" import { authOptions } from "./options"; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }

This code ensures that our authentication routes are configured and ready to handle the required requests seamlessly. You won’t see any changes on the application as we didn’t set up any rules for access control, but if you go directly to http://localhost:3000/api/auth/signin you can sign in with GitHub.

Accessing Session Data and Securing Pages

Now that we have our authentication setup done, we would like to access the session data and secure the access to pages and API routes. How we do this varies depending on where we want to access the session.

Server Components and Route Handlers

For server components or Route Handlers, we use the getServerSession function provided by the library. Using the getServerSession function requires passing the same options object we defined before and also used to initialize NextAuth and set up the Route Handlers for Auth.js. This is the reason why we put that options object on its own file, so we can import it easily in different places.

import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/options"; export async function GET() { const session = await getServerSession(authOptions); if (!session) { return Response.json({ message: "Not authenticated" }, { status: 401 }) } return Response.json({ message: "Hello from the API" }, { status: 200 }) }

getServerSession returns the session for authenticated users and null if the user is not authenticated. We can use this to allow or deny access to an api endpoint or to a page.

Client Pages and Components

For the client side NextAuth provides a useSession() React Hook that we can use to access the session data and check if someone is signed in or not.

The useSession() hook returns an object containing the data and status. Status could be used to know if a user is logged in, as it can be any of the following three states: "loading", "authenticated" or "unauthenticated". For authenticated users data will contain the session data, including by default the name and email of the user.

But before we implement this, we should set up a SessionProvider component that wraps up all other components. By using React Context, the <SessionProvider> component allows different instances of useSession() to share the session object across components. It also ensures that the session is updated and synchronized between multiple browser tabs and windows.

We can do this in different ways, but I like to create a simple AuthProvider client component that will be used to wrap all the content on our Next.js Layout. We will call this component NextAuthProvider.

#/src/app/context/NextAuthProvider.tsx 'use client' import { SessionProvider } from 'next-auth/react' export default function NextAuthProvider({ children }: { children: React.ReactNode }) { return ( <SessionProvider> {children} </SessionProvider> ) }

And our root layout will looks something like this:

#/src/app/layout.tsx import NextAuthProvider' from './context/NextAuthProvider' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <NextAuthProvider> {children} <NextAuthProvider> </body> </html> ) }

Now that we have all this setup we can use the useSession hook to access the session data on any client component on our application and use that information to restrict access or do some action.

"use client" import { useSession, signIn, signOut } from "next-auth/react" export default function ClientComponent() { const { data: session } = useSession() if (session) { return ( <> <h2>Session Data:</h2> <pre>{JSON.stringify(session, null, 2)}</pre> <button onClick={() => signOut()}>Sign out</button> </> ) } return ( <> Not signed in <br /> <button onClick={() => signIn()}>Sign in</button> </> ) }

Middleware

Next.js 12 introduced the concept of middleware. This new feature allows you to run server-side code before your actual page's functions. It's the perfect place to handle page-level authentication and access control before the pages are rendered.

Middleware in Next.js is a function that receives a NextRequest object and returns a NextResponse object or a Promise that resolves to a NextResponse object. It can be used to modify the request or the response objects, or to decide what to do next based on the request.

One of the limitations of NextAuth.js and Middleware is that in the current version it only works with jwt tokens, and does not with database sessions. We will revisit this latter, in the adapters section.

Since Next.js 13 the middleware function can be defined inside a middleware.ts (or middleware.js) file stored on the src folder. Here we will have the logic for access to pages on our application.

If we want to restrict access to all pages unless to authenticated users we just have to add this to the middleware file.

export { default } from "next-auth/middleware"

If you only want certain pages to require authentication to access them you can use the matcher to filter the Middleware to only run on the pages you want. Here you have a simple example, but you can have multiple paths and use named parameters or regular expressions on them.

export const config = { matcher: '/private-page', }

We will see an example of a more complex middleware function when we look into Role-based access control.

Database Adapters

We haven't set up a database until now, and as you can see, NextAuth.js can function without one. It achieves this by creating sessions using JSON Web Tokens (JWT), which are encrypted cookies containing the session data stored on the client's browser.

This approach works well, and in many cases, it will be sufficient. However, if you want to have a place to store data for users, or have server side sessions, you would need to configure a database adapter.

There is a good number of official adapters currently provided by the Auth.js project, including adapters for MongoDB, PostgreSQL, Prisma, and Firebase, and you could write your own adapter if your database is not supported yet.

In our case, we need the database to have the ability to add a role to each user, as our authentication provider (GitHub) does not handle this. Other authentication providers, such as Auth0 and Keycloak, support Role-Based Access Control, and if you are comfortable using JWT, configuring a database may not be necessary.

For demonstration purposes we will set up a simple SQLite database using Prisma as ORM. SQLite is a file based database that does not require a separate database server.

First, we install the dependencies for Prisma into our project.

npm install @prisma/client @next-auth/prisma-adapter npm install prisma --save-dev

Then, we have to create a schema for our database. We do this by creating a schema.prisma file inside the prisma directory. This file contains both the information about the database and the database model.

datasource db { provider = "sqlite" url = "file:./dev.sqlite" } generator client { provider = "prisma-client-js" } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? refresh_token_expires_in Int? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] } model VerificationToken { identifier String token String @unique expires DateTime @@unique([identifier, token]) }

Now we can generate the Prisma client and configure the database with the schema defined before using the migration command.

npx prisma generate npx prisma migrate dev

Finally, we add the database adapter to the Next Auth options object.

import { PrismaAdapter } from "@next-auth/prisma-adapter" import { PrismaClient } from "@prisma/client" const prisma = new PrismaClient() export const authOptions = { adapter: PrismaAdapter(prisma), providers: [ // Add your chosen authentication providers here ], }

By setting up a database adapter, NextAuth.js will default the session behavior to the database. So if we want to use middleware and we set up a database adapter we have to change the options object and set the session behavior to jwt.

export const authOptions = { session: { strategy: "jwt", }, adapter: PrismaAdapter(prisma),

Role-based access control

Up until now, we have performed authentication, verifying that the user is who they claim to be. For some applications, such as a social network, this verification might be sufficient. However, in many other scenarios, we want to determine if the user has the right to access a specific page or perform a particular task. A common method to achieve this is Role-Based Access Control (RBAC). Roles are defined based on authority and responsibility within the application, and users are assigned to those roles, acquiring permission to perform certain actions.

User roles could come directly from our authentication provider. However, for this example, we are using GitHub, a provider that does not support role management. Instead, we will store the role for each user in the database as part of the user model.

To achieve this, we add a role column or field to the user model in our database. We are using Prisma, but this works similarly with other types of databases; the only variation is in how you add that role field or column.

Let’s see how we do this with Prisma. To keep things simple we will define that in our application each user can only have one role, but you can extend this to allow multiple roles per user.

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? role String? accounts Account[] sessions Session[] }

And run the Prisma migration command to add the newly added column to the database.

npx prisma migrate dev --name roles

Once we add that role field to the database model, we need to update the user profile. To do this, we have to make two changes. First, we need to edit our options object to extend the profile and add a couple of callback functions to include the role in the token and session.

export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, adapter: PrismaAdapter(prisma), providers: [ Github({ profile(profile: GithubProfile) { return { id: profile.id.toString(), role: profile.role ?? "user", name: profile.name, email: profile.email, image: profile.avatar_url, } }, clientId: process.env.GITHUB_ID as string, clientSecret: process.env.GITHUB_SECRET as string, }), ], callbacks: { jwt({ token, user }) { if(user) token.role = user.role return token }, session({ session, token }) { session.user.role = token.role return session } } }

But that’s not all. If you are using TypeScript, as we are doing in this demo example, you will have to extend the user, jwt, and session types to include the role. Otherwise, you will encounter type errors. To extend these class types, create a 'types' folder at the root of the project and inside it, add a file called 'next-auth.d.ts.' You can use any name you prefer, but it must end with '*.d.ts.'

Finally, we can use the newly added role to define access rules on our pages, components and middleware. We can use getServerSession or useSession as described before to access the session on server and client components. The role will be part of the session, allowing us to apply any logic needed to either allow or restrict access.

'use client' import { useSession } from "next-auth/react" export default function Page() { const session = await useSession() if (session?.user.role === "admin") { return <p>You are an admin</p> } return <p>You are not an admin</p> }

For controlling access to pages we can use middleware. The example below only allows access to all pages under /admin/ to users with an “admin” role. Users that are not logged in will be redirected to the login page, and logged in users with any other role are redirected to a denied page. This could be useful to restrict the access to an admin section of an application.

import { withAuth } from "next-auth/middleware" import { NextResponse } from "next/server" export default withAuth( function middleware(req) { if (req.nextauth.token?.role !== "admin") { return NextResponse.rewrite( new URL("/denied", req.url) ) } }, { callbacks: { authorized: ({ token }) => !!token, }, } ) export const config = { matcher: ["/admin"] }

Conclusion

Next.js, by default, does not provide native authentication and access control. However, with NextAuth.js, implementing authentication on a Next.js website becomes a relatively straightforward process.

One aspect we haven't covered in this article is user management. You will need to implement a mechanism to assign the correct role to users. Additionally, for this demo, I aimed to keep things simple. However, if your application requires more granular access control, you might consider developing your own role and permissions management, and utilizing libraries like CASL.

It's worth noting that, as mentioned at the beginning of this article, NextAuth.js is currently undergoing a rebranding process and transitioning to Auth.js. As of writing this article, version 5 of Auth.js was in beta, introducing many changes that could potentially simplify aspects covered here. I intend to provide an update to this article once Auth.js v5 is officially released.