DEV Community

Cover image for Complete Guide to Role-Based Access Control (RBAC) in Next.js 14: From Basic Roles to Enterprise Permissions
sizan mahmud0
sizan mahmud0

Posted on

Complete Guide to Role-Based Access Control (RBAC) in Next.js 14: From Basic Roles to Enterprise Permissions

Authentication answers "Who are you?" but authorization answers "What can you do?" Role-Based Access Control (RBAC) is the most widely adopted authorization pattern, used by everything from small startups to Fortune 500 companies. In this comprehensive guide, we'll build a production-ready RBAC system in Next.js 14 that scales from simple admin/user roles to complex permission-based systems.

Understanding RBAC: Why It Matters

Imagine your SaaS application without proper access control:

  • A regular user deletes your entire product database
  • An intern accesses sensitive financial reports
  • A customer views other customers' private data

RBAC prevents these nightmares by defining who can do what. Instead of checking permissions for every user individually, you assign users to roles, and roles have predefined permissions.

RBAC Core Concepts

The Three Pillars

1. Users: People using your application
2. Roles: Groups with specific permissions (Admin, Editor, Viewer)
3. Permissions: Specific actions (create:post, delete:user, view:analytics)

Role Hierarchy Example

Super Admin
  β”œβ”€β”€ Can do everything
  └── Manage all users and roles

Admin
  β”œβ”€β”€ Manage users
  β”œβ”€β”€ View analytics
  └── Edit content

Editor
  β”œβ”€β”€ Create/Edit content
  └── View analytics (read-only)

Viewer
  └── View content only
Enter fullscreen mode Exit fullscreen mode

Building RBAC in Next.js 14: Step-by-Step

Step 1: Define Your Permission System

Create lib/permissions.ts:

// Define all possible permissions in your system
export enum Permission {
  // User management
  VIEW_USERS = 'view:users',
  CREATE_USERS = 'create:users',
  EDIT_USERS = 'edit:users',
  DELETE_USERS = 'delete:users',

  // Content management
  VIEW_POSTS = 'view:posts',
  CREATE_POSTS = 'create:posts',
  EDIT_POSTS = 'edit:posts',
  DELETE_POSTS = 'delete:posts',
  PUBLISH_POSTS = 'publish:posts',

  // Analytics
  VIEW_ANALYTICS = 'view:analytics',
  EXPORT_ANALYTICS = 'export:analytics',

  // Settings
  EDIT_SETTINGS = 'edit:settings',
  VIEW_AUDIT_LOGS = 'view:audit_logs',
}

// Define roles and their permissions
export enum Role {
  SUPER_ADMIN = 'super_admin',
  ADMIN = 'admin',
  EDITOR = 'editor',
  VIEWER = 'viewer',
  USER = 'user',
}

// Map roles to permissions
export const rolePermissions: Record<Role, Permission[]> = {
  [Role.SUPER_ADMIN]: Object.values(Permission), // All permissions

  [Role.ADMIN]: [
    Permission.VIEW_USERS,
    Permission.CREATE_USERS,
    Permission.EDIT_USERS,
    Permission.VIEW_POSTS,
    Permission.CREATE_POSTS,
    Permission.EDIT_POSTS,
    Permission.DELETE_POSTS,
    Permission.PUBLISH_POSTS,
    Permission.VIEW_ANALYTICS,
    Permission.EXPORT_ANALYTICS,
    Permission.VIEW_AUDIT_LOGS,
  ],

  [Role.EDITOR]: [
    Permission.VIEW_POSTS,
    Permission.CREATE_POSTS,
    Permission.EDIT_POSTS,
    Permission.VIEW_ANALYTICS,
  ],

  [Role.VIEWER]: [
    Permission.VIEW_POSTS,
    Permission.VIEW_ANALYTICS,
  ],

  [Role.USER]: [
    Permission.VIEW_POSTS,
  ],
};

// Helper function to check if role has permission
export function hasPermission(role: Role, permission: Permission): boolean {
  return rolePermissions[role]?.includes(permission) ?? false;
}

// Helper function to check multiple permissions (user must have ALL)
export function hasAllPermissions(role: Role, permissions: Permission[]): boolean {
  return permissions.every(permission => hasPermission(role, permission));
}

// Helper function to check if user has ANY of the permissions
export function hasAnyPermission(role: Role, permissions: Permission[]): boolean {
  return permissions.some(permission => hasPermission(role, permission));
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Update JWT to Include Role

Modify lib/jwt.ts:

import { SignJWT, jwtVerify } from 'jose';
import { Role } from './permissions';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'fallback-secret-key'
);

export interface JWTPayload {
  userId: string;
  email: string;
  role: Role;
  permissions?: string[]; // Optional: embed permissions for performance
}

export async function signToken(payload: JWTPayload) {
  return await new SignJWT({ ...payload })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret);
}

export async function verifyToken(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload as JWTPayload;
  } catch (error) {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create Authorization Utilities

Create lib/rbac.ts:

import { cookies } from 'next/headers';
import { verifyToken } from './jwt';
import { Permission, hasPermission, Role } from './permissions';

export async function getCurrentUser() {
  const cookieStore = cookies();
  const token = cookieStore.get('token')?.value;

  if (!token) return null;

  const payload = await verifyToken(token);
  if (!payload) return null;

  return {
    id: payload.userId,
    email: payload.email,
    role: payload.role,
  };
}

export async function requireAuth() {
  const user = await getCurrentUser();
  if (!user) {
    throw new Error('Unauthorized');
  }
  return user;
}

export async function requireRole(allowedRoles: Role[]) {
  const user = await requireAuth();

  if (!allowedRoles.includes(user.role)) {
    throw new Error('Forbidden: Insufficient permissions');
  }

  return user;
}

export async function requirePermission(permission: Permission) {
  const user = await requireAuth();

  if (!hasPermission(user.role, permission)) {
    throw new Error(`Forbidden: Missing permission ${permission}`);
  }

  return user;
}

export async function requirePermissions(permissions: Permission[]) {
  const user = await requireAuth();

  const missingPermissions = permissions.filter(
    permission => !hasPermission(user.role, permission)
  );

  if (missingPermissions.length > 0) {
    throw new Error(
      `Forbidden: Missing permissions ${missingPermissions.join(', ')}`
    );
  }

  return user;
}

// Check if current user can perform action (non-throwing version)
export async function can(permission: Permission): Promise<boolean> {
  const user = await getCurrentUser();
  if (!user) return false;
  return hasPermission(user.role, permission);
}

// Check if current user has role (non-throwing version)
export async function isRole(role: Role): Promise<boolean> {
  const user = await getCurrentUser();
  if (!user) return false;
  return user.role === role;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Protect API Routes

Create app/api/users/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/rbac';
import { Permission } from '@/lib/permissions';

// GET /api/users - View users list
export async function GET(request: NextRequest) {
  try {
    const user = await requirePermission(Permission.VIEW_USERS);

    // Fetch users from database
    // const users = await db.user.findMany();

    return NextResponse.json({
      users: [
        { id: '1', email: 'admin@example.com', role: 'admin' },
        { id: '2', email: 'user@example.com', role: 'user' },
      ],
    });
  } catch (error) {
    if (error.message === 'Unauthorized') {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }
    return NextResponse.json(
      { error: 'Forbidden: You do not have permission to view users' },
      { status: 403 }
    );
  }
}

// POST /api/users - Create new user
export async function POST(request: NextRequest) {
  try {
    const user = await requirePermission(Permission.CREATE_USERS);

    const body = await request.json();
    const { email, role, name } = body;

    // Validation
    if (!email || !role) {
      return NextResponse.json(
        { error: 'Email and role are required' },
        { status: 400 }
      );
    }

    // Create user in database
    // const newUser = await db.user.create({
    //   data: { email, role, name }
    // });

    return NextResponse.json(
      { message: 'User created', user: { email, role, name } },
      { status: 201 }
    );
  } catch (error) {
    if (error.message === 'Unauthorized') {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }
    return NextResponse.json(
      { error: 'Forbidden: You do not have permission to create users' },
      { status: 403 }
    );
  }
}

// DELETE /api/users/[id] - Delete user
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const user = await requirePermission(Permission.DELETE_USERS);

    // Prevent deleting yourself
    if (params.id === user.id) {
      return NextResponse.json(
        { error: 'Cannot delete your own account' },
        { status: 400 }
      );
    }

    // Delete user from database
    // await db.user.delete({ where: { id: params.id } });

    return NextResponse.json({ message: 'User deleted' });
  } catch (error) {
    if (error.message === 'Unauthorized') {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }
    return NextResponse.json(
      { error: 'Forbidden: You do not have permission to delete users' },
      { status: 403 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Protect Pages with Middleware

Update middleware.ts:

import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/jwt';
import { hasPermission, Permission, Role } from '@/lib/permissions';

// Define route permissions
const routePermissions: Record<string, Permission[]> = {
  '/dashboard/users': [Permission.VIEW_USERS],
  '/dashboard/analytics': [Permission.VIEW_ANALYTICS],
  '/dashboard/settings': [Permission.EDIT_SETTINGS],
  '/dashboard/posts/new': [Permission.CREATE_POSTS],
};

// Define routes that require specific roles
const routeRoles: Record<string, Role[]> = {
  '/admin': [Role.ADMIN, Role.SUPER_ADMIN],
  '/admin/audit': [Role.SUPER_ADMIN],
};

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;
  const { pathname } = request.nextUrl;

  // Public routes
  const publicRoutes = ['/login', '/register', '/', '/about'];
  const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route));

  // If accessing protected route without token
  if (!token && !isPublicRoute) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // If has token, verify and check permissions
  if (token && !isPublicRoute) {
    const payload = await verifyToken(token);

    if (!payload) {
      // Invalid token
      const response = NextResponse.redirect(new URL('/login', request.url));
      response.cookies.delete('token');
      return response;
    }

    // Check role-based restrictions
    for (const [route, allowedRoles] of Object.entries(routeRoles)) {
      if (pathname.startsWith(route)) {
        if (!allowedRoles.includes(payload.role)) {
          return NextResponse.redirect(new URL('/forbidden', request.url));
        }
      }
    }

    // Check permission-based restrictions
    for (const [route, requiredPermissions] of Object.entries(routePermissions)) {
      if (pathname.startsWith(route)) {
        const hasAllPermissions = requiredPermissions.every(permission =>
          hasPermission(payload.role, permission)
        );

        if (!hasAllPermissions) {
          return NextResponse.redirect(new URL('/forbidden', request.url));
        }
      }
    }
  }

  // If accessing auth routes with valid token, redirect to dashboard
  if (token && (pathname.startsWith('/login') || pathname.startsWith('/register'))) {
    const payload = await verifyToken(token);
    if (payload) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Enter fullscreen mode Exit fullscreen mode

Step 6: Client-Side Permission Checks

Create components/PermissionGate.tsx:

'use client';

import { useAuth } from '@/contexts/AuthContext';
import { Permission, hasPermission } from '@/lib/permissions';
import { ReactNode } from 'react';

interface PermissionGateProps {
  permission: Permission;
  children: ReactNode;
  fallback?: ReactNode;
}

export function PermissionGate({ 
  permission, 
  children, 
  fallback = null 
}: PermissionGateProps) {
  const { user } = useAuth();

  if (!user || !hasPermission(user.role, permission)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

// Usage with multiple permissions
interface MultiPermissionGateProps {
  permissions: Permission[];
  requireAll?: boolean; // true = AND logic, false = OR logic
  children: ReactNode;
  fallback?: ReactNode;
}

export function MultiPermissionGate({
  permissions,
  requireAll = true,
  children,
  fallback = null,
}: MultiPermissionGateProps) {
  const { user } = useAuth();

  if (!user) return <>{fallback}</>;

  const checkFunction = requireAll
    ? permissions.every(p => hasPermission(user.role, p))
    : permissions.some(p => hasPermission(user.role, p));

  if (!checkFunction) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Create components/RoleGate.tsx:

'use client';

import { useAuth } from '@/contexts/AuthContext';
import { Role } from '@/lib/permissions';
import { ReactNode } from 'react';

interface RoleGateProps {
  allowedRoles: Role[];
  children: ReactNode;
  fallback?: ReactNode;
}

export function RoleGate({ allowedRoles, children, fallback = null }: RoleGateProps) {
  const { user } = useAuth();

  if (!user || !allowedRoles.includes(user.role)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Create Auth Context

Create contexts/AuthContext.tsx:

'use client';

import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Role } from '@/lib/permissions';

interface User {
  id: string;
  email: string;
  role: Role;
  name?: string;
}

interface AuthContextType {
  user: User | null;
  loading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  hasRole: (role: Role) => boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Fetch current user on mount
    fetch('/api/auth/me')
      .then(res => res.json())
      .then(data => {
        if (data.user) {
          setUser(data.user);
        }
      })
      .catch(error => console.error('Failed to fetch user:', error))
      .finally(() => setLoading(false));
  }, []);

  const login = async (email: string, password: string) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    const data = await response.json();
    setUser(data.user);
  };

  const logout = async () => {
    await fetch('/api/auth/logout', { method: 'POST' });
    setUser(null);
  };

  const hasRole = (role: Role) => {
    return user?.role === role;
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout, hasRole }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Build Protected UI Components

Create app/dashboard/users/page.tsx:

import { requirePermission } from '@/lib/rbac';
import { Permission } from '@/lib/permissions';
import { redirect } from 'next/navigation';
import { PermissionGate } from '@/components/PermissionGate';
import { CreateUserButton } from '@/components/CreateUserButton';
import { DeleteUserButton } from '@/components/DeleteUserButton';

export default async function UsersPage() {
  try {
    await requirePermission(Permission.VIEW_USERS);
  } catch (error) {
    redirect('/forbidden');
  }

  // Fetch users from API
  const response = await fetch('http://localhost:3000/api/users', {
    cache: 'no-store',
  });
  const data = await response.json();

  return (
    <div className="p-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">User Management</h1>

        <PermissionGate permission={Permission.CREATE_USERS}>
          <CreateUserButton />
        </PermissionGate>
      </div>

      <div className="bg-white rounded-lg shadow">
        <table className="min-w-full">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Email
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Role
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            {data.users.map((user: any) => (
              <tr key={user.id}>
                <td className="px-6 py-4 whitespace-nowrap">{user.email}</td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
                    {user.role}
                  </span>
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <PermissionGate permission={Permission.EDIT_USERS}>
                    <button className="text-blue-600 hover:text-blue-800 mr-3">
                      Edit
                    </button>
                  </PermissionGate>

                  <PermissionGate permission={Permission.DELETE_USERS}>
                    <DeleteUserButton userId={user.id} />
                  </PermissionGate>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

Pattern 1: Resource-Based Permissions

Sometimes you need to check if a user owns a resource:

export async function canEditPost(postId: string): Promise<boolean> {
  const user = await getCurrentUser();
  if (!user) return false;

  // Admins can edit any post
  if (hasPermission(user.role, Permission.EDIT_POSTS)) {
    return true;
  }

  // Regular users can only edit their own posts
  const post = await db.post.findUnique({ where: { id: postId } });
  return post?.authorId === user.id;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Hierarchical Roles

Implement role hierarchy where higher roles inherit lower role permissions:

const roleHierarchy: Record<Role, number> = {
  [Role.SUPER_ADMIN]: 5,
  [Role.ADMIN]: 4,
  [Role.EDITOR]: 3,
  [Role.VIEWER]: 2,
  [Role.USER]: 1,
};

export function hasRoleLevel(userRole: Role, requiredRole: Role): boolean {
  return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Dynamic Permissions

Load permissions from database for flexibility:

// Store in database
interface UserPermission {
  userId: string;
  permissions: Permission[];
  expiresAt?: Date;
}

export async function getUserPermissions(userId: string): Promise<Permission[]> {
  const user = await db.user.findUnique({
    where: { id: userId },
    include: { customPermissions: true }
  });

  // Combine role permissions with custom permissions
  const rolePerms = rolePermissions[user.role];
  const customPerms = user.customPermissions.map(p => p.permission);

  return [...new Set([...rolePerms, ...customPerms])];
}
Enter fullscreen mode Exit fullscreen mode

Testing RBAC

import { describe, it, expect } from 'vitest';
import { hasPermission, Permission, Role } from '@/lib/permissions';

describe('RBAC System', () => {
  it('should allow admin to view users', () => {
    expect(hasPermission(Role.ADMIN, Permission.VIEW_USERS)).toBe(true);
  });

  it('should not allow viewer to delete users', () => {
    expect(hasPermission(Role.VIEWER, Permission.DELETE_USERS)).toBe(false);
  });

  it('should allow super admin all permissions', () => {
    Object.values(Permission).forEach(permission => {
      expect(hasPermission(Role.SUPER_ADMIN, permission)).toBe(true);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

1. Client-Side Only Checks: Always validate permissions on the server. Client-side gates are for UX only.

2. Hardcoded Roles: Use enums and constants instead of strings like if (role === 'admin').

3. Missing Permission Checks: Every API endpoint and page must check permissions.

4. Over-Permissive Defaults: Deny by default, grant explicitly.

5. Forgetting Edge Cases: What happens when a user's role changes mid-session?

Monitoring and Auditing

Track permission checks for security auditing:

export async function auditPermissionCheck(
  userId: string,
  permission: Permission,
  granted: boolean,
  resource?: string
) {
  await db.auditLog.create({
    data: {
      userId,
      action: permission,
      granted,
      resource,
      timestamp: new Date(),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Role-Based Access Control transforms your Next.js application from a single-tier system into a secure, scalable platform ready for real-world use. Start with basic roles, add permissions as you grow, and implement advanced patterns when complexity demands it.

The key is consistency: check permissions everywhere, server-side first, and provide clear feedback when access is denied. Your users will appreciate the security, and your future self will thank you for the maintainable code.

What's your approach to authorization? Are you using RBAC or exploring ABAC (Attribute-Based Access Control)? Share your experiences in the comments!

Top comments (0)