ProgrUmar Logo
Module 5: Authentication & Authorization

Role-based Access Control (RBAC)

Duration: 18 mins

Adding Roles to Your Session

Extend the Auth.js session type to include a role field populated from your database. Check session.user.role in middleware, Server Components, and Server Actions to gate access at every layer.

Role-based Access Control (RBAC)

Simply confirming whether a user is logged in is rarely enough for production platforms. A functional software-as-a-service (SaaS) needs to distinguish between regular customers, content editors, and system administrators. Role-based Access Control (RBAC) allows you to restrict permissions based on user classifications. In this lesson, we will learn how to extend session token properties in Auth.js v5 using TypeScript module augmentation, gate client interfaces, and secure API routes and Server Actions dynamically.


1. Designing the User Roles Schema

To implement RBAC, your database must store user classifications. Using a PostgreSQL database with Prisma or Drizzle, you typically model this using a role Enumeration (Enum) or a standardized string parameter on your User table.

Here is an example database model configuration:

// schema.prisma
enum Role {
  USER
  EDITOR
  ADMIN
}

model User {
  id    String @id @default(uuid())
  email String @unique
  role  Role   @default(USER)
}

2. Extending Session Types in Auth.js v5

By default, Auth.js session profiles only return basic parameters: name, email, and image. To read custom values like role on client or server scopes, you must augment the NextAuth TypeScript module types.

Create a type definitions file inside your project structure (for instance, types/next-auth.d.ts):

// types/next-auth.d.ts
import NextAuth, { type DefaultSession } from 'next-auth';

export type UserRole = 'ADMIN' | 'EDITOR' | 'USER';

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

  interface User {
    role: UserRole;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    role: UserRole;
  }
}

3. Populating Roles with JWT and Session Callbacks

Once your types are augmented, you must update the authentication pipeline to extract the role from your database during login and attach it to both the JWT token and the session object:

// auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export const { handlers, auth, signIn, signOut } = NextAuth({
  ...authConfig,
  callbacks: {
    // 1. Populate the JWT token with the user's role from the database
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
      }
      return token;
    },
    // 2. Expose the role from the token to the client-facing session
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role;
      }
      return session;
    },
  },
});

4. Gating Layouts in Server Components

After populating roles inside your session configuration, you can inspect permissions inside async Server Components. If the active session does not meet requirements, redirect the client immediately:

// app/admin/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function AdminDashboard() {
  const session = await auth();

  // Redirect users who are not administrators
  if (!session || session.user.role !== 'ADMIN') {
    redirect('/unauthorized');
  }

  return (
    <main className="p-8">
      <h1>Welcome to the Admin Portal</h1>
      <p>Authorized access granted.</p>
    </main>
  );
}

5. Gating UI Components Conditionally

On the client side, you can verify role properties using the useSession Hook to toggle actions or dashboard links without triggering full-page redirects:

// components/Sidebar.tsx
'use client';

import { useSession } from 'next-auth/react';
import Link from 'next/link';

export default function Sidebar() {
  const { data: session } = useSession();

  return (
    <aside className="w-64 bg-neutral-50 p-4">
      <nav className="space-y-2 flex flex-col">
        <Link href="/dashboard">General Dashboard</Link>
        
        {/* Only display the Admin panel link to administrators */}
        {session?.user.role === 'ADMIN' && (
          <Link href="/admin/dashboard" className="text-red-600">
            Admin Settings
          </Link>
        )}
      </nav>
    </aside>
  );
}

6. Securing Server Actions

Gating UI routes protects visual directories, but malicious users can bypass layouts to trigger mutations directly. You must validate role permissions inside your Server Actions before running database operations:

// app/actions/admin-actions.ts
'use server';

import { auth } from '@/auth';

export async function deleteUserAction(userId: string) {
  const session = await auth();

  // Enforce server-side security checks
  if (!session || session.user.role !== 'ADMIN') {
    throw new Error('Forbidden: Insufficient privileges.');
  }

  // Execute database deletion query safely on the server
  await fetch('https://api.progrumar.com/users/' + userId, {
    method: 'DELETE',
  });

  return { success: true };
}

7. Securing Route Handlers

Similar checks must apply to API endpoints. The auth() wrapper function in Auth.js v5 can also wrap standard GET/POST Route Handlers, letting you extract the active token cleanly:

// app/api/admin/reports/route.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export const GET = auth(async function GET(req) {
  // auth() automatically injects session parameters into the request context
  const session = req.auth;

  if (!session || session.user.role !== 'ADMIN') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const reports = await fetch('https://api.progrumar.com/reports').then(r => r.json());
  return NextResponse.json(reports);
});

8. Protecting Dynamic Routing Rules inside Middleware

If you want to handle role checks centrally in middleware instead of writing checks in every file, inspect the session token inside middleware.ts:

// middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth((req) => {
  const session = req.auth;
  const { pathname } = req.nextUrl;

  if (pathname.startsWith('/admin') && session?.user.role !== 'ADMIN') {
    return Response.redirect(new URL('/unauthorized', req.nextUrl.origin));
  }
});

export const config = {
  matcher: ['/admin/:path*'],
};

9. Common Gotchas

  • Security Checks Placed Only in Client UI: Client-side component gating (e.g. session?.user.role === 'ADMIN' && <Button>) is easily bypassed by modifying JavaScript variables in the browser. You must validate the role claim on the server inside Server Actions and Route Handlers for true security.
  • Stale Session Role Claims: If an administrator demotes a user's role in the database, the user will retain admin access until their browser cookie expires or their token is re-validated. Ensure you configure low session expiry times or query the live database inside high-risk Server Actions instead of relying entirely on cached cookies.
  • TypeScript Compilation Errors: Forgetting to augment the next-auth interfaces will throw typescript compiler errors when you try to access session.user.role. Always verify that your definition files (.d.ts) are loaded inside tsconfig.json.

Key Takeaways

  • Define role parameters in your database schemas and map them to user profiles.
  • Use TypeScript namespace augmentation to register custom session parameters like roles.
  • Populate the JWT and Session callbacks inside Auth.js to pass the user's role to the frontend.
  • Always gate Server Actions and Route Handlers; client-side UI gating can be bypassed.
  • Enforce strict role checks in Edge Middleware to protect admin folders globally.

Securing access permissions protects your admin systems and user listings. But to build a dynamic SaaS, you must store, update, and search data. In the next module, we transition to **Database Integration** with a lesson on **Choosing a Database for Next.js**, exploring serverless databases and object-relational mapping (ORM) setups.

Chat with us