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
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));
}
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;
}
}
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;
}
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 }
);
}
}
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).*)'],
};
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}</>;
}
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}</>;
}
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;
}
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>
);
}
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;
}
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];
}
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])];
}
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);
});
});
});
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(),
},
});
}
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)