
Role-Based Access Control in Next.js 15 with Auth.js v5
Clerk will handle your RBAC in five minutes, and they'll charge you for the privilege. If you want the same protection with Auth.js v5, your own database, and no vendor lock-in — here's the full implementation for Next.js 15 App Router.
The problem with most role-based access control guides isn't the concept — it's the code. They show you Pages Router patterns in an App Router world. They use getServerSession from Auth.js v4 when v5 has a completely different session API. They protect routes in middleware.ts without mentioning that middleware alone isn't enough for server actions. And not one of them mentions CVE-2025-29927, the middleware bypass vulnerability that made headlines in early 2025.
This guide fixes all of that. You get a working middleware.ts for Next.js 15 edge runtime, Auth.js v5 session configuration with role types, a Drizzle ORM schema you can copy, and protection patterns for server components and server actions. Everything is TypeScript strict mode. No any. No getServerSession. No Pages Router.
What "Unified RBAC" Actually Means
Most RBAC implementations scatter logic everywhere: a check in the route handler, a duplicate check in the component, another one buried in a server action. Six months later you're auditing a security incident trying to figure out which check was missing.
The unified pattern puts access decisions in one place. Your middleware.ts is the control plane. Every request passes through it before touching a route handler, server component, or API. Secondary checks in server actions and components exist as defense-in-depth — not as the primary enforcement point.
The data flow looks like this:
HTTP Request
│
▼
middleware.ts ─────── reads session → checks role → allow / redirect
│
▼
Route Handler / Server Component
│
▼
requireRole() helper ─────── secondary check (defense-in-depth)
│
▼
Database / Business Logic
The advantage is auditability. When a security review asks "where is access controlled?", the answer is middleware.ts plus a requireRole helper — two files, not twenty.
Setting Up Roles in Auth.js v5
TypeScript Types First
Auth.js v5 ships with a session type that has no role field by default. You extend it with module augmentation.
// types/next-auth.d.ts
import { DefaultSession, DefaultJWT } from "next-auth"
export enum UserRole {
SUPER_ADMIN = "SUPER_ADMIN",
ADMIN = "ADMIN",
USER = "USER",
API = "API",
ANONYMOUS = "ANONYMOUS",
}
declare module "next-auth" {
interface Session {
user: {
id: string
role: UserRole
} & DefaultSession["user"]
}
interface User {
role: UserRole
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
role: UserRole
}
}
The enum is the single source of truth for role names. It lives here, gets imported into your database schema, your middleware, and your server actions. No magic strings anywhere.
Configuring Auth.js v5
// auth.ts
import NextAuth from "next-auth"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/db"
import { users } from "@/db/schema"
import { eq } from "drizzle-orm"
import { UserRole } from "@/types/next-auth"
import Credentials from "next-auth/providers/credentials"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: "jwt" },
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await db.query.users.findFirst({
where: eq(users.email, credentials.email as string),
})
if (!user) return null
// verify password with your preferred library (bcrypt, argon2, etc.)
const isValid = await verifyPassword(
credentials.password as string,
user.passwordHash
)
if (!isValid) return null
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role as UserRole,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
// First sign-in: persist role from the User object into the JWT
token.id = user.id
token.role = user.role ?? UserRole.USER
}
return token
},
async session({ session, token }) {
// Shape the session object that server components and middleware see
if (token) {
session.user.id = token.id
session.user.role = token.role
}
return session
},
},
})
Three things to understand here:
session: { strategy: "jwt" }is required for edge-compatible middleware. Database sessions don't work at the edge.callbacks.jwtruns on sign-in and on every token refresh. Only write to the token whenuseris present — that's the sign-in event.callbacks.sessionshapes whatauth()returns. This is what your components and middleware actually consume.
The auth export is the v5 pattern. You'll use it everywhere: const session = await auth() in server components, auth() as middleware wrapper, auth() in server actions.
Drizzle ORM — Role Schema
Drizzle is now the default for greenfield Next.js projects. The schema is TypeScript-native, migrations are explicit, and the query builder doesn't hide what SQL it generates.
// db/schema.ts
import {
pgTable,
text,
timestamp,
pgEnum,
uuid,
boolean,
} from "drizzle-orm/pg-core"
// Mirror the UserRole enum from your auth types
export const roleEnum = pgEnum("user_role", [
"SUPER_ADMIN",
"ADMIN",
"USER",
"API",
"ANONYMOUS",
])
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified", { mode: "date" }),
image: text("image"),
passwordHash: text("password_hash"),
role: roleEnum("role").notNull().default("USER"),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(),
})
// Auth.js adapter tables
export const accounts = pgTable("accounts", {
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
provider: text("provider").notNull(),
providerAccountId: text("provider_account_id").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: timestamp("expires_at", { mode: "date" }),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
})
export const sessions = pgTable("sessions", {
sessionToken: text("session_token").primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
})
export const verificationTokens = pgTable("verification_tokens", {
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
})
The pgEnum keeps your database and TypeScript in sync. Add a role once — in both roleEnum and UserRole — and TypeScript will surface every place that needs updating.
Prisma alternative — if you're on a Prisma-based project:
// schema.prisma
enum UserRole {
SUPER_ADMIN
ADMIN
USER
API
ANONYMOUS
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
passwordHash String?
role UserRole @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
}
The rest of this guide uses Drizzle, but the patterns are identical. The Prisma adapter for Auth.js v5 is @auth/prisma-adapter.
middleware.ts — Next.js 15 App Router
This is the heart of the unified RBAC pattern. One file, all access rules.
// middleware.ts (project root)
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import { UserRole } from "@/types/next-auth"
// Route configuration: map path prefixes to required roles
const PROTECTED_ROUTES: Record<string, UserRole[]> = {
"/admin": [UserRole.SUPER_ADMIN, UserRole.ADMIN],
"/api/admin": [UserRole.SUPER_ADMIN, UserRole.ADMIN],
"/api/internal": [UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.API],
"/dashboard": [UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.USER],
"/api/user": [UserRole.SUPER_ADMIN, UserRole.ADMIN, UserRole.USER],
}
// Routes that require any valid session (login required, no specific role)
const AUTH_REQUIRED_ROUTES = ["/profile", "/settings", "/onboarding"]
export default auth((req) => {
const { nextUrl, auth: session } = req
const pathname = nextUrl.pathname
// Check role-protected routes first
for (const [prefix, allowedRoles] of Object.entries(PROTECTED_ROUTES)) {
if (pathname.startsWith(prefix)) {
if (!session?.user) {
// Not logged in — redirect to sign-in
const signInUrl = new URL("/auth/signin", nextUrl.origin)
signInUrl.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(signInUrl)
}
const userRole = session.user.role as UserRole
if (!allowedRoles.includes(userRole)) {
// Logged in but wrong role — redirect to unauthorized page
return NextResponse.redirect(new URL("/unauthorized", nextUrl.origin))
}
return NextResponse.next()
}
}
// Check auth-required routes
for (const prefix of AUTH_REQUIRED_ROUTES) {
if (pathname.startsWith(prefix)) {
if (!session?.user) {
const signInUrl = new URL("/auth/signin", nextUrl.origin)
signInUrl.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(signInUrl)
}
}
}
return NextResponse.next()
})
export const config = {
// Match all routes except static files and Next.js internals
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
}
Three things to understand about this middleware:
It uses auth() from Auth.js v5, not getToken from next-auth/jwt. The v5 pattern wraps your middleware handler with auth(handler). The session is available as req.auth inside the handler. This runs on Next.js 15's edge runtime without modification.
JWT session strategy is required. Edge runtime doesn't support database I/O. If your auth.ts uses strategy: "database", this middleware will fail silently. The JWT strategy keeps everything in the signed cookie — no database round-trip needed at the edge.
Route matching is explicit. The config.matcher excludes static assets. Everything else goes through the middleware. This is intentional — you want auth checks on API routes too, not just UI routes.
CVE-2025-29927 — The Middleware Bypass You Need to Know
In March 2025, security researchers disclosed CVE-2025-29927, a critical vulnerability in Next.js middleware that allowed attackers to bypass middleware entirely by setting the x-middleware-subrequest header on requests.
The attack was straightforward: if a request carried this internal Next.js header, the framework's request handling logic would skip middleware execution and route the request directly to the page or API handler. Any access control logic in middleware.ts — including RBAC — was simply bypassed.
Is Auth.js v5 affected? The vulnerability is in Next.js itself, not in Auth.js. It affects any Next.js application that relies on middleware for security enforcement — regardless of which auth library is in use. Auth.js v5 was not broken; the middleware layer below it was.
Is it patched? Yes. The fix was released in Next.js 15.2.3 (and backported to 14.x and 13.x). If you're on an older version, update immediately.
npm install next@latest
# or
pnpm add next@latest
How to verify you're protected:
# Check your Next.js version — must be ≥ 15.2.3
npx next --version
Defense-in-depth matters. This vulnerability is the reason middleware-only RBAC is insufficient. Had developers also implemented server-side role checks (covered in the next section), the bypass would have been caught at the next layer. The x-middleware-subrequest header would have allowed the request through middleware, but the server component or server action would have still rejected it.
The lesson: middleware is the first check, not the only check. Defense-in-depth is the right architecture regardless of which CVEs exist today.
Additional mitigation — if you want to be explicit:
// middleware.ts — add this before your route checks
export default auth((req) => {
// Explicitly strip the internal header to prevent bypass attempts
// (Next.js ≥ 15.2.3 handles this, but belt-and-suspenders is fine)
const requestHeaders = new Headers(req.headers)
requestHeaders.delete("x-middleware-subrequest")
const { nextUrl, auth: session } = req
// ... rest of your middleware logic
})
Protecting Server Components and Server Actions
Middleware is your control plane. Server-side checks are your defense-in-depth.
Server Components
// app/admin/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import { UserRole } from "@/types/next-auth"
export default async function AdminPage() {
const session = await auth()
if (!session?.user) {
redirect("/auth/signin")
}
if (session.user.role !== UserRole.ADMIN && session.user.role !== UserRole.SUPER_ADMIN) {
redirect("/unauthorized")
}
return (
<div>
<h1>Admin Panel</h1>
{/* admin content */}
</div>
)
}
For production code, extract the check into a reusable helper:
// lib/auth-guards.ts
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import { UserRole } from "@/types/next-auth"
export async function requireRole(
...allowedRoles: UserRole[]
): Promise<NonNullable<Awaited<ReturnType<typeof auth>>>["user"]> {
const session = await auth()
if (!session?.user) {
redirect("/auth/signin")
}
const role = session.user.role as UserRole
if (!allowedRoles.includes(role)) {
redirect("/unauthorized")
}
return session.user
}
// Convenience wrappers
export const requireAdmin = () =>
requireRole(UserRole.ADMIN, UserRole.SUPER_ADMIN)
export const requireSuperAdmin = () => requireRole(UserRole.SUPER_ADMIN)
Usage becomes a one-liner:
// app/admin/users/page.tsx
import { requireAdmin } from "@/lib/auth-guards"
export default async function UsersPage() {
const user = await requireAdmin()
// user.id and user.role are available here — TypeScript knows the types
return <UserTable currentUserId={user.id} />
}
Server Actions
Server actions need explicit protection. Middleware doesn't intercept them — they're called directly via POST to a Next.js internal endpoint.
// app/admin/actions.ts
"use server"
import { requireAdmin } from "@/lib/auth-guards"
import { db } from "@/db"
import { users } from "@/db/schema"
import { eq } from "drizzle-orm"
import { UserRole } from "@/types/next-auth"
import { revalidatePath } from "next/cache"
export async function updateUserRole(userId: string, newRole: UserRole) {
// This check runs server-side, before any database call
const currentUser = await requireAdmin()
// Prevent privilege escalation: only SUPER_ADMIN can grant SUPER_ADMIN
if (
newRole === UserRole.SUPER_ADMIN &&
currentUser.role !== UserRole.SUPER_ADMIN
) {
throw new Error("Insufficient permissions to grant SUPER_ADMIN role")
}
await db
.update(users)
.set({ role: newRole, updatedAt: new Date() })
.where(eq(users.id, userId))
revalidatePath("/admin/users")
}
The requireAdmin() call at the top of the server action is mandatory. If someone bypasses your UI and posts directly to the action endpoint (which is trivially easy), the middleware check won't fire. The server-side check will.
Drizzle Queries for Role Management
// lib/users.ts
import { db } from "@/db"
import { users } from "@/db/schema"
import { eq } from "drizzle-orm"
import { UserRole } from "@/types/next-auth"
// Fetch a user with their role
export async function getUserById(id: string) {
return db.query.users.findFirst({
where: eq(users.id, id),
columns: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
},
})
}
// List all users with a specific role
export async function getUsersByRole(role: UserRole) {
return db
.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.role, role))
}
// Update a user's role with an audit record
export async function setUserRole(
targetUserId: string,
newRole: UserRole,
changedById: string
) {
return db.transaction(async (tx) => {
await tx
.update(users)
.set({ role: newRole, updatedAt: new Date() })
.where(eq(users.id, targetUserId))
// Write audit log (see Swiss compliance section below)
await tx.insert(accessLogs).values({
actorId: changedById,
targetId: targetUserId,
action: "ROLE_CHANGE",
metadata: JSON.stringify({ newRole }),
createdAt: new Date(),
})
})
}
The transaction on setUserRole ensures the role update and audit log are atomic. If the log write fails, the role change rolls back. This matters for compliance — a role change without an audit record is as bad as no role change at all.
Clerk and WorkOS — When Managed RBAC Makes Sense
This guide shows you how to build RBAC without a managed service. But Clerk and WorkOS exist for good reasons. Here's an honest comparison.
Clerk
Clerk is the most developer-friendly option for rapid prototyping and early-stage SaaS. The dashboard is excellent, the Next.js SDK is first-class, and you can add RBAC in an afternoon. Their "Organizations" feature handles multi-tenant role management — each org has its own roles and members.
Where Clerk becomes painful: cost at scale (pricing tiers based on monthly active users can surprise you at growth), vendor lock-in (your users and sessions live on Clerk's infrastructure), and limited control over the session token structure. If you're processing data under Swiss DSG or GDPR, you'll need to review Clerk's data processing agreements carefully — user data, including roles and access patterns, lives on their servers.
Choose Clerk when: you're building a prototype, have a small team, or need enterprise SSO (SAML, OIDC) without building it yourself.
WorkOS
WorkOS targets enterprise customers. It's not a direct competitor to Clerk for indie hackers — it's purpose-built for companies adding SSO and directory sync to their SaaS product for enterprise deals. The RBAC module is mature, and WorkOS handles the SAML integration complexity that would otherwise take months.
The tradeoff is pricing. WorkOS is structured for B2B SaaS at meaningful scale, not for startups. The free tier is generous for development but production pricing is enterprise-grade.
Choose WorkOS when: enterprise SSO is a sales blocker and your customers are large organizations with Active Directory or Okta requirements.
Auth.js v5 Self-Hosted
What this guide covers. You own the stack, the data, and the costs. The implementation takes a day — this guide is most of it. The ongoing maintenance overhead is minimal once it's running.
Choose Auth.js v5 self-hosted when: you're building for a cost-conscious team, need full control over session data and user records, or your compliance requirements (Swiss DSG, GDPR, HIPAA) make third-party user data storage complicated.
At Bi·Catalyst, we run Auth.js v5 self-hosted across our production stack. Our clients' data stays on our infrastructure, and the compliance story is clean. For a new SaaS with six users? Start with Clerk. For production enterprise software or regulated-industry applications? Own your auth stack.
Swiss Compliance — RBAC, Audit Trails, and the DSG
Swiss DSG / FADP Callout
If your application processes data belonging to Swiss residents or operates under Swiss law, RBAC alone is not enough. The Federal Act on Data Protection (FADP / DSG), which became fully enforceable in September 2023, requires more than access restrictions.
Article 8 FADP — Automated decision-making: If your application makes automated decisions based on user data (profiling, scoring, eligibility checks), affected persons have a right to know how that decision was made — and who had access to that data when the decision occurred. Your RBAC implementation needs to produce an auditable record.
What this means in practice:
- Log every role-based access decision: who accessed what resource, when, with which role
- Log role changes: who changed which user's role, when, and from/to which values
- Retain logs for the duration required by your data retention policy (Art. 6 FADP)
- Make logs available for review in the event of an access request or supervisory inquiry
The
accessLogstable in the Drizzle schema and thesetUserRoletransaction above are starting points. Extend them with a per-request access log if your application handles particularly sensitive data.For broader AI and LLM compliance context, see our guide on securing the full stack: LLM security starts with proper access control.
Here's the minimal audit log schema:
// db/schema.ts (add to existing schema)
export const accessLogs = pgTable("access_logs", {
id: uuid("id").primaryKey().defaultRandom(),
actorId: uuid("actor_id").references(() => users.id),
targetId: uuid("target_id"),
action: text("action").notNull(), // e.g., "ROLE_CHANGE", "RESOURCE_ACCESS", "ADMIN_ACTION"
resourcePath: text("resource_path"), // e.g., "/admin/users"
metadata: text("metadata"), // JSON string with additional context
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
})
And a middleware extension that logs access to sensitive routes:
// middleware.ts — add logging to the role-check block
if (pathname.startsWith(prefix)) {
if (session?.user) {
const userRole = session.user.role as UserRole
if (allowedRoles.includes(userRole)) {
// Log the successful access asynchronously
// Note: direct DB calls aren't available in edge runtime
// Use a background fetch to your own API route instead
fetch(new URL("/api/internal/log-access", nextUrl.origin), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
actorId: session.user.id,
action: "RESOURCE_ACCESS",
resourcePath: pathname,
role: userRole,
}),
}).catch(() => {
// Non-blocking — don't fail the request if logging fails
})
}
}
}
The fire-and-forget pattern keeps the middleware fast. The log endpoint (/api/internal/log-access) is a standard route handler with database access — no edge constraints.
Putting It Together — The Checklist
Before shipping role-based access control to production, verify each layer:
Auth.js v5 setup
- Module augmentation in
types/next-auth.d.tsextendsSession,User, andJWTwithrole -
callbacks.jwtwritesroleto token on sign-in -
callbacks.sessionpropagatesrolefrom token to session - Session strategy is
jwt(required for edge middleware)
Database
-
pgEnumfor roles matchesUserRoleTypeScript enum - Default role is
USER, notADMIN -
accessLogstable exists for audit trail
Middleware
- Next.js version ≥ 15.2.3 (CVE-2025-29927 patch)
-
auth()wrapper used, notgetToken() - Route matcher excludes static assets
- Unauthenticated requests redirect to sign-in with
callbackUrl - Wrong-role requests redirect to
/unauthorized, not a 401 JSON response (UI routes need redirects)
Server-side defense-in-depth
- Every admin server component calls
requireAdmin()or equivalent - Every admin server action calls the role guard before any database operation
- Privilege escalation checks: lower roles cannot grant higher roles
Compliance (Swiss DSG / regulated)
- Role changes are logged in
accessLogs - Access to sensitive routes is logged
- Log retention policy is defined
Internal Links
For related patterns in the same codebase, see:
- Code-level vulnerabilities in a real Next.js app — RBAC is one layer; this covers the others
- How a phishing attack exploited our Next.js setup — why security layers beyond access control matter
- Other Next.js full-stack patterns — App Router pagination and server-side data patterns
Frequently Asked Questions
What is the difference between authentication and authorization in Next.js?
Authentication answers "who are you?" — it verifies identity, typically via a session or JWT. Authorization answers "what are you allowed to do?" — it checks the authenticated identity against access rules. In Next.js 15, Auth.js v5 handles authentication (session management, token signing). Your RBAC implementation handles authorization (role checks in middleware, server components, and server actions). They work at different layers. You need both.
Is Auth.js v5 compatible with Next.js 15 App Router?
Yes, and it's the recommended setup as of 2026. Auth.js v5 was redesigned around the App Router — the new auth() function works in server components, server actions, and edge middleware without the adapter workarounds that v4 required. The getServerSession() pattern from v4 is deprecated. Use import { auth } from "@/auth" and const session = await auth() throughout.
Should I use Clerk or Auth.js for RBAC in Next.js?
It depends on your constraints. Clerk is faster to set up and excellent for prototypes or small teams who need enterprise SSO quickly. Auth.js v5 self-hosted gives you full control over user data, no vendor pricing surprises, and a cleaner compliance story for regulated industries. If you're building for Swiss clients or handling sensitive data, owning your auth stack removes a third-party data processor from your compliance scope. If you're moving fast and don't need that control, Clerk is a legitimate choice.
What is CVE-2025-29927 and does it affect my RBAC implementation?
CVE-2025-29927 is a Next.js middleware bypass vulnerability disclosed in March 2025. Attackers could set an internal Next.js header (x-middleware-subrequest) on requests, causing the framework to skip middleware execution entirely — including any RBAC checks in middleware.ts. The vulnerability is patched in Next.js 15.2.3 and later. Update immediately if you haven't. The second takeaway: middleware-only RBAC is insufficient. The server-side requireRole() checks described in this guide would catch a bypass that slips through middleware.
How do I add roles to the Auth.js session?
Three steps: (1) extend the session type with module augmentation in types/next-auth.d.ts; (2) write the role to the JWT in callbacks.jwt when user is present (sign-in event); (3) propagate the role from token to session in callbacks.session. The full code is in the "Setting Up Roles in Auth.js v5" section above. The most common mistake is writing to the token without the if (user) guard — without that guard, a token refresh clears the role.
At Bi·Catalyst, we specialize in engineering and developing custom software tailored to your unique needs. If you have an idea you want to bring to life, don't hesitate to get in touch. with us, and let's transform your vision into reality. Your journey to bespoke software solutions begins here with Bi·Catalyst.💡



