Repository navigation
Best architecture for multi-layered RBAC in Next.js (org-level + user-level permissions) #97509
SummaryHello, I want to take some advice from you guys. Please give me some help and idea. I'm building a Next.js app where each logged-in user has two separate permission layers: (1) service access granted to their organization, and (2) service access granted to the individual user. These combined permissions need to control route access, menu visibility, and component-level visibility within the same page. Currently this logic is scattered across middleware, NextAuth callbacks, per-page checks, and client-side global state — it's become messy and hard to maintain. Looking for a cleaner architectural pattern or library recommendation to centralize this.
Additional informationNo response ExampleNo response |
Replies: 1 comment 3 replies
|
Hey, I have a thought I’d like to share: An RBAC blueprint for the Next.js App Router. To clean up "permission spaghetti," we need to decouple Authentication (who you are), Permission Computation/Authorization (what you can do), and Enforcement (blocking routes or hiding buttons). This distinction is also consistent with Next.js' authentication guidance: authentication establishes identity, while authorization determines what that identity is allowed to access. ref Here are the steps: 1. The Single Source of Truth: Core Engine Do not use CASL or external SaaS platforms (like Permit.io) unless you need complex graph-based relationships or a compliance UI. For an Org + User matrix, a simple, pure TypeScript utility file is faster, strongly typed, and easier to maintain. Create a central permission engine that defines your actions and resolves overlaps. Centralizing this logic also follows OWASP's guidance to use a site-wide mechanism for authorization rather than scattering access-control decisions throughout the application. ref // types/auth.ts
export type Service = 'analytics' | 'billing' | 'ai_tools' | 'settings';
export type Action = 'view' | 'create' | 'edit' | 'delete';
export interface UserSessionData {
orgServices: Service[]; // Tier 1: What the company paid for
userPermissions: string[]; // Tier 2: e.g., ["analytics:view", "analytics:edit"]
}
// lib/permissions.ts
import { UserSessionData, Service, Action } from '@/types/auth';
export function createAbility(session: UserSessionData | null) {
if (!session) {
return { can: () => false };
}
// 1. Compute effective permissions by cross-referencing Org and User tiers
const can = (service: Service, action: Action): boolean => {
// Tier 1 Check: Does the organization even have access to this service?
if (!session.orgServices.includes(service)) return false;
// Tier 2 Check: Does the individual user have permission for this action?
const requiredPermission = `${service}:${action}`;
return session.userPermissions.includes(requiredPermission);
};
return { can };
}2. NextAuth (Auth.js) Layer: Fetching the Data Keep your NextAuth callbacks strictly focused on fetching raw data, not computing logic. I would keep the Auth.js layer responsible for authentication/session representation rather than making it the canonical authorization engine. Auth.js is primarily an authentication/session framework, while authorization can remain in a separate domain module. NextAuth should just act as the data passport. ref // auth.ts (NextAuth v5 configuration)
import NextAuth from "next-auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
callbacks: {
async jwt({ token, user }) {
if (user) {
// Fetch raw permissions from your database during login
const dbData = await fetchUserAndOrgPermissions(user.id);
token.orgServices = dbData.orgServices;
token.userPermissions = dbData.userPermissions;
}
return token;
},
async session({ session, token }) {
// Inject raw arrays into the session object
session.user.orgServices = token.orgServices as string[];
session.user.userPermissions = token.userPermissions as string[];
return session;
}
}
});3. Route & Page Layer: Server-Side Enforcement Do not run complex service-level permissions inside middleware.ts. Use Proxy for coarse-grained request gating, not as the only authorization layer. Instead, use a two-step gatekeeping approach: Step A: Proxy (The Blunt Instrument) Use middleware only to check if a user is logged in or if they belong to a general route path (e.g., // proxy.ts
export { auth as proxy } from "@/auth";
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};Step B: Layouts & Pages (The Scalpel) Use Next.js Server Components to securely pull the session, run it through your central permission engine, and block access or hide layout sidebars before rendering code to the client. // app/dashboard/analytics/page.tsx
import { auth } from "@/auth";
import { createAbility } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { AnalyticsDashboard } from "@/components/AnalyticsDashboard";
export default async function AnalyticsPage() {
const session = await auth();
const { can } = createAbility(session?.user);
// Secure Server-side block
if (!can('analytics', 'view')) {
redirect('/dashboard/unauthorized');
}
// Pass permissions down cleanly as basic booleans to UI
return (
<AnalyticsDashboard
canEdit={can('analytics', 'edit')}
canDelete={can('analytics', 'delete')}
/>
);
}4. UI Layer: Sidebar & Component Visibility To control layout sidebars and deep client buttons cleanly without prop-drilling, wrap your layout in a lightweight React Context Provider.Step A: Create the Auth Provider // context/PermissionContext.tsx
'use client';
import { createContext, useContext } from 'react';
import { createAbility } from '@/lib/permissions';
import { UserSessionData } from '@/types/auth';
const PermissionContext = createContext<ReturnType<typeof createAbility> | null>(null);
export function PermissionProvider({
children,
sessionData
}: {
children: React.ReactNode,
sessionData: UserSessionData | null
}) {
const ability = createAbility(sessionData);
return (
<PermissionContext.Provider value={ability}>
{children}
</PermissionContext.Provider>
);
}
// Custom hook for client components
export function usePermission() {
const context = useContext(PermissionContext);
if (!context) throw new Error("usePermission must be used within PermissionProvider");
return context;
}Step B: Inject at the Dashboard Root Layout // app/dashboard/layout.tsx
import { auth } from "@/auth";
import { PermissionProvider } from "@/context/PermissionContext";
import { Sidebar } from "@/components/Sidebar";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
return (
<PermissionProvider sessionData={session?.user || null}>
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
</PermissionProvider>
);
}Step C: Hide Elements in the UI This is a presentation concern, not a security boundary. Hiding a button or sidebar item improves UX, but a user can still manually call the underlying endpoint. OWASP explicitly warns against relying on client-side access-control checks. ref Now, any client-side sidebar link or nested button can call the exact same can() engine directly, keeping the UI consistent with the same authorization policy. // components/Sidebar.tsx (Client Component)
'use client';
import { usePermission } from "@/context/PermissionContext";
import Link from "next/link";
export function Sidebar() {
const { can } = usePermission();
return (
<nav>
{can('analytics', 'view') && <Link href="/dashboard/analytics">Analytics</Link>}
{can('ai_tools', 'view') && <Link href="/dashboard/ai">AI Toolkit</Link>}
{can('settings', 'view') && <Link href="/dashboard/settings">Settings</Link>}
</nav>
);
}Summary Checklist of this Architecture
|
Thanks! I think you've identified an important extra dimension: the backend route/RBAC contract should probably be treated as an input to the authorization architecture, rather than creating another independent permission layer in the frontend.
If the backend is authoritative about both:
I'd avoid maintaining a separate frontend mapping like:
in multiple places. That's where the spaghetti can come back.
Instead, I'd think about the architecture as: