NextAuth.js Authentication Series — Part 10 ~ Role-Based Authentication & Authorization with Auth.js
In the previous article, we learned how to protect pages and API routes using Auth.js.At that point, our application could answer an important question:Is this...

In the previous article, we learned how to protect pages and API routes using Auth.js.
At that point, our application could answer an important question:
Is this user authenticated?
But real-world applications usually need to answer another question:
What is this authenticated user allowed to do?
For example, imagine an application with two types of users:
Admin
User
Both users can log in, but they shouldn't have the same permissions.
An admin might be allowed to:
Manage users
View admin dashboards
Delete content
Manage application settings
A normal user might only be allowed to:
View their profile
Update their own information
Access regular application features
This is where Role-Based Access Control (RBAC) comes in.
In this article, we'll add roles to our authentication system and use them to control access to pages and API routes.
Authentication vs Authorization
Before implementing roles, it's important to understand the difference.
Authentication
Authentication answers:
Who is this user?
For example:
User:
john@example.com
Authorization
Authorization answers:
What is this user allowed to access?
For example:
User:
john@example.com
Role:
user
The user is authenticated, but they may not be authorized to access:
/admin
So:
Authentication
↓
Who are you?
Authorization
↓
What can you access?
Step 1 — Add a Role to Users
Our MongoDB user document currently looks similar to:
{
"name": "John Doe",
"email": "john@example.com",
"password": "$2b$12$...",
"createdAt": "...",
"updatedAt": "..."
}
We'll add a role field:
{
"name": "John Doe",
"email": "john@example.com",
"password": "$2b$12$...",
"role": "user",
"createdAt": "...",
"updatedAt": "..."
}
For an administrator:
{
"name": "Admin User",
"email": "admin@example.com",
"password": "$2b$12$...",
"role": "admin",
"createdAt": "...",
"updatedAt": "..."
}
Step 2 — Define Available Roles
Instead of using arbitrary strings throughout the application, it's better to define the available roles in one place.
Create:
lib/roles.ts
export const ROLES = {
ADMIN: "admin",
USER: "user",
} as const;
export type Role = (typeof ROLES)[keyof typeof ROLES];
Now TypeScript knows that our application supports:
admin
user
This reduces spelling mistakes such as:
admn
admni
Admin
administrator
Step 3 — Assign a Default Role During Registration
Open your registration API:
app/api/auth/register/route.ts
When creating a new user:
await db.collection("users").insertOne({
name,
email,
password: hashedPassword,
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
});
New users will automatically receive:
role = user
This is important because users should not be allowed to choose:
{
"role": "admin"
}
during public registration.
Otherwise, anyone could register themselves as an administrator.
Step 4 — Create an Admin User
For development, you can manually create an admin user in MongoDB.
For example:
{
"name": "Admin",
"email": "admin@example.com",
"password": "$2b$12$...",
"role": "admin"
}
The password must be a bcrypt hash.
Never store:
{
"password": "admin123"
}
in a real application.
For production systems, admin creation should use a controlled process such as an internal admin tool, deployment seed, or secure account provisioning workflow.
Step 5 — Return the Role During Authentication
Open your Auth.js configuration.
Inside the Credentials Provider's authorize() function, return the user's role.
return {
id: user._id.toString(),
name: user.name,
email: user.email,
role: user.role,
};
Now the authenticated user object contains:
id
name
email
role
Step 6 — Add the Role to the JWT
Next, we need to transfer the role into the JWT.
Inside the jwt callback:
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
}
When the user first signs in:
User
│
├── id
├── name
├── email
└── role
│
▼
JWT
Step 7 — Add the Role to the Session
Now expose the role through the session.
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
}
Now the session can contain:
{
"user": {
"id": "123",
"name": "John Doe",
"email": "john@example.com",
"role": "user"
}
}
Step 8 — Update TypeScript Definitions
Because role isn't included in the default Auth.js session type, we need to extend it.
Update:
types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
role: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
}
}
Now TypeScript understands:
session.user.role
and:
token.role
Step 9 — Protect the Admin Dashboard
Create:
app/admin/page.tsx
Then check both authentication and authorization:
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== "admin") {
redirect("/dashboard");
}
return (
<main>
<h1>Admin Dashboard</h1>
<p>Welcome, {session.user.name}</p>
</main>
);
}
Now there are two checks:
Is the user authenticated?
│
▼
Yes
│
▼
Is the user an admin?
│
┌───┴───┐
Yes No
│ │
▼ ▼
Admin Dashboard
Step 10 — Protect Admin APIs
Authorization shouldn't only happen on the page.
Suppose we have:
/api/admin/users
We should protect the API as well.
Create:
app/api/admin/users/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{
message: "Unauthorized",
},
{
status: 401,
}
);
}
if (session.user.role !== "admin") {
return NextResponse.json(
{
message: "Forbidden",
},
{
status: 403,
}
);
}
return NextResponse.json({
message: "Admin data",
});
}
Notice the difference between:
401 Unauthorized
and:
403 Forbidden
401 vs 403
A useful rule is:
401 — Not Authenticated
The user hasn't successfully authenticated.
No valid session
403 — Not Authorized
The user is authenticated but doesn't have permission.
Logged in user
+
Insufficient permissions
For example:
Anonymous User
↓
/admin
↓
401
While:
Normal User
↓
/admin
↓
403
The exact response behavior can vary by application, but this distinction is useful when designing APIs.
Step 11 — Create a Reusable Role Check
As the application grows, you don't want to repeat:
session.user.role !== "admin"
everywhere.
We can create a helper.
Create:
lib/authorization.ts
import { Role } from "@/lib/roles";
export function hasRole(
userRole: string | undefined,
requiredRole: Role
) {
return userRole === requiredRole;
}
Now:
import { hasRole } from "@/lib/authorization";
if (!hasRole(session.user.role, "admin")) {
// Forbidden
}
This keeps authorization logic reusable.
Step 12 — Supporting Multiple Roles
As applications become larger, you may have:
admin
manager
editor
user
Instead of checking only one role, you can create a helper that accepts multiple allowed roles.
import { Role } from "@/lib/roles";
export function hasAnyRole(
userRole: string | undefined,
allowedRoles: Role[]
) {
if (!userRole) {
return false;
}
return allowedRoles.includes(userRole as Role);
}
Then:
if (!hasAnyRole(session.user.role, ["admin", "manager"])) {
// Forbidden
}
This allows both admins and managers to access a resource.
Role-Based Access Control Flow
Our system now looks like:
User
│
▼
Login
│
▼
Auth.js
│
▼
JWT
│
▼
Session
│
┌─────┴─────┐
│ │
Admin User
│ │
▼ ▼
Admin Dashboard Dashboard
Authentication determines whether the user is logged in.
Authorization determines what the user can access.
Important Security Consideration
Never trust a role supplied by the client.
For example, don't allow a request like:
{
"role": "admin"
}
to determine authorization.
The role should come from a trusted server-side source and be included in the authenticated session through your authentication configuration.
A malicious user should never be able to turn:
user
into:
admin
by modifying a request.
Role Changes and Existing Sessions
There is another important consideration.
Suppose a user's role changes:
user → admin
or:
admin → user
If role information is stored in a JWT, the existing token may continue to contain the old role until the session/token is refreshed or replaced.
This means applications that require immediate permission changes should carefully consider session strategy, token lifetime, and how authorization data is refreshed.
For highly sensitive admin systems, don't assume that changing the database role automatically changes every already-issued session immediately.
Project Structure
Our authentication project now looks like:
app/
├── api/
│ ├── auth/
│ │ ├── [...nextauth]/
│ │ │ └── route.ts
│ │ └── register/
│ │ └── route.ts
│ │
│ └── admin/
│ └── users/
│ └── route.ts
│
├── admin/
│ └── page.tsx
│
├── dashboard/
│ └── page.tsx
│
└── register/
└── page.tsx
lib/
├── auth.ts
├── authorization.ts
├── mongodb.ts
├── password.ts
├── roles.ts
└── validations/
└── auth.ts
types/
└── next-auth.d.ts
Key Takeaways
In this article, we learned:
Authentication and authorization are different concepts.
Users can have different roles.
New users should receive a safe default role.
Roles can be passed through the Auth.js JWT.
Roles can be exposed through the session.
Admin pages need authorization checks.
Admin APIs need authorization checks too.
401and403represent different situations.Authorization logic can be extracted into reusable helpers.
Client-provided roles should never be trusted.
JWT-based role changes may not take effect immediately for existing sessions.
Our authentication system now supports:
Registration
↓
Password Hashing
↓
MongoDB
↓
Credentials Login
↓
JWT Session
↓
Authentication
↓
Authorization
↓
Role-Based Access
What's Next?
Our application now supports registration, login, sessions, protected routes, and role-based authorization.
But there's still a common problem:
What happens when a user forgets their password?
A production authentication system needs a secure password recovery mechanism.
In the next article, we'll build a Forgot Password & Reset Password System.
We'll cover:
Forgot password form
Secure reset token generation
Token expiration
Storing reset tokens securely
Sending a password reset email
Reset password page
Updating the user's bcrypt password
Invalidating the reset token after use
This will turn our basic authentication system into a much more complete authentication flow.
Series Navigation
NextAuth.js Authentication Series
✅ Part 1 — Introduction to Authentication with NextAuth.js
✅ Part 2 — Installing & Configuring NextAuth.js
✅ Part 3 — Google OAuth Authentication
✅ Part 4 — Credentials Provider with Email & Password
✅ Part 5 — Integrating MongoDB with Credentials Authentication
✅ Part 6 — Secure Password Authentication with bcrypt
✅ Part 7 — Building a User Registration System
✅ Part 8 — JWT & Session Management
✅ Part 9 — Protecting Routes with Auth.js
✅ Part 10 — Role-Based Authentication & Authorization ← You are here


