Role-Based Access Control in Next.js with Middleware and RBAC
Lesson 5.4 covered authentication — verifying who a user is. This lesson covers the closely related but distinct concept of authorization: once you know who a user is, what are they actually allowed to do? A logged-in user and an admin logged-in user are both 'authenticated,' but only one of them should be able to delete other users' accounts or access a billing dashboard.
Role-Based Access Control (RBAC) is the standard pattern for solving this: assigning each user one or more roles (like 'admin', 'editor', 'viewer'), and checking those roles at every point where access should be restricted — in middleware for broad route protection, and again inside Server Components and Server Actions for more granular, resource-specific checks. This lesson covers implementing RBAC correctly across all these layers.
Learning Objectives
- Distinguish between authentication (who you are) and authorization (what you're allowed to do).
- Store and check user roles as part of a Next.js application's data model.
- Enforce role-based restrictions in Next.js middleware for broad route protection.
- Enforce more granular, resource-specific permission checks inside Server Components and Server Actions.
- Apply defense-in-depth by checking authorization at multiple layers rather than just one.
Core Definitions
- Authentication: Verifying a user's identity — confirming they are who they claim to be, typically via login credentials.
- Authorization: Determining what an already-authenticated user is allowed to do or access, based on their role or specific permissions.
- Role: A named category (like 'admin', 'editor', 'viewer') assigned to a user, used to group together a related set of permissions.
- RBAC (Role-Based Access Control): An authorization model where access decisions are based on the role(s) assigned to a user, rather than checking individual permissions one by one.
- Defense in depth: A security principle of applying the same authorization check redundantly at multiple layers (e.g., middleware AND the component itself), so a gap in one layer doesn't fully compromise security.
Detailed Explanation
The distinction between authentication and authorization is foundational: authentication answers 'who is this user?' (handled by Auth.js in the previous lesson), while authorization answers 'is this specific, already-identified user allowed to do this specific thing?' A completely valid, successfully logged-in user might still be forbidden from accessing an admin dashboard or deleting another user's post — that's an authorization decision, layered on top of successful authentication.
Implementing RBAC starts with your data model: a User (in your Prisma schema, from Lesson 5.2) typically gets a role field — `role String @default('user')` or, more robustly, an enum type with defined values like 'user', 'editor', 'admin'. When a session is created (via Auth.js), this role should be included as part of the session data, making it available anywhere you can access the current session, without needing a separate database lookup just to check a role.
The broadest layer of enforcement is Next.js middleware, extending the authentication-checking middleware from the previous lesson to also check the user's role: `if (req.auth?.user.role !== 'admin' && req.nextUrl.pathname.startsWith('/admin')) { return redirect('/unauthorized') }`. This stops unauthorized users before they ever reach an admin-only section of the site, providing broad, efficient, framework-level protection for entire route trees.
But middleware-level checks alone aren't always sufficient for more granular, resource-specific authorization — for example, allowing any 'editor' to edit blog posts in general, but only allowing an editor to edit their OWN specific posts, not everyone else's. This kind of fine-grained, data-dependent check requires looking at the actual resource being accessed, which typically means checking authorization again inside the specific Server Component or Server Action handling that operation: fetching the post, comparing its authorId to the current session's user ID, and only proceeding if they match (or if the user's role grants broader access, like an admin being able to edit any post regardless of authorship).
This layered approach — a broad role check in middleware, plus a more specific, resource-aware check inside the actual Server Action or Server Component performing the operation — exemplifies the defense-in-depth security principle: even if a gap existed in the middleware's matcher configuration (missing a specific route pattern), the deeper, more specific check inside the actual mutation logic would still correctly prevent unauthorized access. Never relying on just one layer of authorization checking is a hallmark of a genuinely secure application.
Where Authorization Checks Happen: Middleware vs Component vs Action
{"heading":"Where Authorization Checks Happen: Middleware vs Component vs Action","description":"Visualize the layered defense-in-depth approach:\n\n[Request to /admin/users] --> [middleware.ts: is role === 'admin'?] --NO--> [Redirect to /unauthorized]\n --YES--> [Continue to the page]\n\n[Server Action: deletePost(postId)]\n --> [Fetch the post] --> [Is post.authorId === session.user.id OR session.user.role === 'admin'?]\n --NO--> [Throw an authorization error, refuse the deletion]\n --YES--> [Proceed with the delete]\n\nBoth layers check authorization independently — a gap in one doesn't compromise the other."}
Next.js Practical Example
// prisma/schema.prisma — adding a role field to the User model
model User {
id String @id @default(cuid())
email String @unique
role String @default("user") // 'user' | 'editor' | 'admin'
}
// middleware.ts — broad, role-based route protection
import { auth } from '@/auth';
export default auth((req) => {
const isAdminRoute = req.nextUrl.pathname.startsWith('/admin');
const userRole = req.auth?.user?.role;
if (isAdminRoute && userRole !== 'admin') {
return Response.redirect(new URL('/unauthorized', req.nextUrl));
}
});
export const config = { matcher: ['/admin/:path*'] };
// app/actions/deletePost.ts — granular, resource-specific authorization inside a Server Action
'use server';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
export async function deletePost(postId: string) {
const session = await auth();
if (!session) throw new Error('Not authenticated');
const post = await prisma.post.findUnique({ where: { id: postId } });
if (!post) throw new Error('Post not found');
const isOwner = post.authorId === session.user.id;
const isAdmin = session.user.role === 'admin';
if (!isOwner && !isAdmin) {
throw new Error('Not authorized to delete this post');
}
await prisma.post.delete({ where: { id: postId } });
}
The User model's role field, defaulting to 'user', stores each user's assigned role directly in the database via Prisma. middleware.ts checks this role (made available through req.auth via Auth.js) for any request under /admin, redirecting anyone who isn't specifically an admin, before that request ever reaches an admin page's actual content — a broad, efficient first layer of defense. deletePost demonstrates the deeper, resource-specific layer: even though middleware might allow a request through (say, this isn't even an /admin route), the Server Action independently verifies that the current user is either the post's actual owner OR an admin before allowing the deletion, refusing the operation entirely otherwise — this check happens regardless of what middleware did or didn't catch, exemplifying defense-in-depth.
Where RBAC Shows Up in Real Products
- SaaS platforms commonly implement at least three roles — viewer, editor, and admin — with middleware protecting entire admin-only sections while granular checks ensure editors can only modify content within their own team or workspace.
- Content management systems restrict publishing and deletion actions to editor or admin roles, while allowing broader read-only access to all authenticated users, enforced both at the route level and within each specific mutation.
- E-commerce admin dashboards use RBAC to distinguish between customer service staff (who can view orders) and finance staff (who can issue refunds), each restricted to their own specific set of allowed actions.
- Healthcare and financial applications, given their regulatory requirements, implement particularly strict, multi-layered RBAC, often auditing every authorization check and denial for compliance purposes.
- Open-source project management tools implement RBAC so that only project owners or designated maintainers can delete a project or remove other members, while regular contributors have more limited permissions.
Common Mistakes to Avoid
- Relying solely on middleware for authorization and skipping resource-specific checks inside Server Actions, missing fine-grained, data-dependent authorization needs.
- Storing a user's role only in the browser (e.g., in a cookie a client could tamper with) rather than deriving it from a trusted, server-verified session.
- Forgetting to update middleware's matcher configuration when adding a new admin-only route, accidentally leaving it unprotected at that layer.
- Hardcoding role checks scattered inconsistently across many files instead of centralizing role/permission logic in a small set of reusable utility functions.
- Confusing authentication failures (401, not logged in at all) with authorization failures (403, logged in but forbidden from this specific action), returning the wrong status code or user-facing message.
Interview Notes
- Authentication verifies identity; authorization determines what an already-authenticated user is allowed to do.
- RBAC assigns users one or more roles, used to group and check related sets of permissions.
- Middleware provides broad, efficient, route-pattern-based authorization; granular, resource-specific checks belong inside the actual Server Component or Server Action.
- Defense in depth means checking authorization redundantly at multiple layers, so a gap in one doesn't fully compromise security.
- A user's role should be derived from a trusted, server-verified session, never solely from client-controlled data.
Key Takeaways
- Authorization is a distinct concept from authentication, and both are necessary for a genuinely secure application.
- RBAC provides a clean, scalable model for managing what different categories of users are allowed to do.
- Middleware and in-component/action checks serve complementary purposes: broad efficiency versus granular, resource-aware precision.
- Defense in depth — checking authorization at multiple layers — is a security best practice that protects against gaps in any single layer.
Summary
Role-Based Access Control (RBAC) addresses authorization — determining what an already-authenticated user (from Lesson 5.4) is actually allowed to do — by assigning each user one or more roles and checking those roles wherever access should be restricted. A role field added to the User model (via Prisma) is included in the session data created by Auth.js, making it available for checking anywhere the session can be read. Next.js middleware provides a broad, efficient first layer of protection, checking a user's role before a request reaches an entire protected route pattern (like everything under /admin). But granular, resource-specific authorization — like ensuring a user can only edit their own posts — requires a deeper check inside the actual Server Component or Server Action handling that specific operation, comparing the resource's data against the current user's identity and role. Applying both layers together exemplifies defense in depth: even if one layer has a gap, the other still correctly enforces authorization.