What NextAuth v5 Actually Changed (And Why Your v4 Code Breaks Silently)
NextAuth v5 changed middleware location, session callback requirements, and host trust configuration—and none of these throw obvious errors, which makes v4 assumptions dangerous to carry forward into a production Next.js 16 SaaS. I run 2 roles, 4 session fields, bcrypt at 12 rounds, proxy.ts redirects in 20 ms, and gate 10 tools on this multi-tenant platform. Platform totals from production: 10 tools, 6 networks, $20-60/month AI spend, 58,641 lines, 4-month solo build.
See also: multi-tenant SaaS with Prisma and PostgreSQL and AES-256-GCM API key encryption.
Five migration shifts I hit on an invite-only platform
I built auth for an affiliate marketing SaaS I maintain—invite-only, zero public signup, two roles (admin and user), one protected admin route at /admin/users. Admins provision accounts manually; regular users never see user management. The stack uses NextAuth v5 with Credentials-only login, JWT sessions lasting thirty days, and bcrypt at twelve rounds. Here are the five v4→v5 changes that matter for RBAC:
- middleware.ts → proxy.ts: route protection moved from the project root to src/proxy.ts, wired through next.config.ts instead of Next.js auto-detection.
- Silent session field dropping: custom fields like role vanish from session.user unless jwt() and session() callbacks explicitly copy them.
- trustHost requirement: Vercel deployments need trustHost: true in config—v4’s NEXTAUTH_URL alone no longer suffices.
- getServerSession() → auth(): one import from src/lib/auth.ts works in Server Components, Server Actions, and Route Handlers.
- Stricter authorize() typing: return values must match augmented User interfaces or TypeScript errors—v4 code ignoring types breaks at runtime instead.
This post walks the complete implementation Hassan Raza documents on hassanr.com: four session fields, proxy.ts guards, inactive-user blocking inside authorize(), and defence-in-depth checks inside Server Components. If you are migrating from v4 or starting fresh with v5 RBAC, these are the files and callbacks that actually matter—not the OAuth provider docs most tutorials still copy.
The platform deliberately ships zero OAuth providers. Google or GitHub login would let unprovisioned identities create sessions, breaking the invite-only constraint at the auth layer itself. Credentials-only with admin-provisioned accounts keeps the front door aligned with business rules: if you are not in Postgres, you cannot authenticate—regardless of which identity provider might vouch for you elsewhere.
That constraint shaped every file in this stack: auth.ts owns callbacks, authorize-credentials.ts owns login gates, proxy.ts owns route boundaries, and admin pages own the second-layer check. None of these files alone delivers RBAC—together they do.
Designing the Session: 4 Fields, 2 Roles, and the JWT Callbacks You Cannot Skip
NextAuth v5 silently drops custom session fields unless jwt() and session() callbacks explicitly copy them—this is the most common reason role disappears from session.user after a v4 migration.
The four fields and why each lives in the JWT
My session exposes id, email, username, and role. The id is the Prisma cuid from Postgres—not NextAuth’s internal subject string. email supports display and audit trails. username is what marketers type at login. role is typed as admin | user union, not string, so TypeScript catches invalid assignments at compile time. All four persist inside the JWT because I chose session strategy jwt with maxAge 2592000 seconds—thirty days—and zero OAuth providers. Credentials-only enforces invite-only access: unprovisioned identities cannot create sessions through Google or GitHub side doors.
Module augmentation is non-optional in TypeScript
NextAuth v5 requires declare module 'next-auth' blocks extending Session and User interfaces. Without augmentation, auth() returns a session missing role even when authorize() returned it correctly. The data flow is linear: authorize() → jwt() on sign-in → session() on every auth() call. Skip either callback and role never reaches Server Components checking session.user.role === 'admin'.
// src/lib/auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { authorizeCredentials } from '@/features/auth/services/authorize-credentials'
/** Extend Session + User so role survives TypeScript checks end-to-end. */
declare module 'next-auth' {
interface Session {
user: {
id: string
email: string
username: string
role: 'admin' | 'user'
}
}
interface User {
id: string
email: string
username: string
role: 'admin' | 'user'
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string
username?: string
role?: 'admin' | 'user'
}
}
const config = {
trustHost: true, // Required on Vercel — v4 used NEXTAUTH_URL alone
providers: [
Credentials({
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' },
},
authorize: authorizeCredentials, // Full implementation in authorize-credentials.ts
}),
],
session: {
strategy: 'jwt' as const,
maxAge: 60 * 60 * 24 * 30, // 30 days = 2,592,000 seconds
},
pages: {
signIn: '/login',
},
callbacks: {
/**
* jwt() runs on sign-in and token refresh.
* Without copying user.role here, role never enters the signed JWT.
*/
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.username = user.username
token.role = user.role
}
return token
},
/**
* session() runs whenever auth() is called.
* Without copying token.role here, session.user.role is undefined in RSC.
*/
async session({ session, token }) {
if (session.user) {
session.user.id = (token.id as string) ?? token.sub ?? ''
session.user.username = token.username as string
session.user.role = token.role as 'admin' | 'user'
}
return session
},
},
}
export const { auth, handlers, signIn, signOut } = NextAuth(config)
Without jwt() copying role into the token and session() copying it onto session.user, role will be undefined in every Server Component and Server Action that calls auth(). NextAuth v5 does not warn you—it silently omits the field. Always log session shape in a test page before building role checks on top of it.
Handlers export to src/app/api/auth/[...nextauth]/route.ts as GET and POST. That wiring stayed familiar from v4; the callback requirements did not.
When I first migrated, authorize() returned role correctly but session.user.role was undefined in every Server Component. The bug lived entirely inside missing jwt() wiring—not the database, not proxy.ts. I spent forty minutes grep-ing Prisma queries before logging token.role inside session(). That debugging pattern repeats across every v4→v5 RBAC migration I have seen: the authorize path works, the session path does not, and NextAuth offers zero console hint about the gap.
The Credentials Provider: Inactive User Blocking and Password Verification
The Credentials provider authorize() function is the only place to enforce account-level rules like isActive blocking—do it here, not in middleware, where you have full database access at login time.
The four-gate authorize() implementation
Every failure returns null, not a thrown error. Null collapses wrong password, missing username, and disabled account into one generic Invalid credentials message—no username enumeration. Passwords hash at account creation with bcryptjs and twelve rounds; authorize() only compares.
// src/features/auth/services/authorize-credentials.ts
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
import type { User } from 'next-auth'
type CredentialsInput = {
username: string
password: string
}
/**
* Gate login before JWT issuance. Runs once per sign-in — not on every request.
* Return null for ALL failures to prevent username enumeration.
*/
export async function authorizeCredentials(
credentials: CredentialsInput | undefined
): Promise<User | null> {
if (!credentials?.username || !credentials?.password) return null
// Gate 1: lookup — null if username unknown (same error as wrong password)
const user = await db.user.findUnique({
where: { username: credentials.username },
select: {
id: true,
email: true,
username: true,
role: true,
hashedPassword: true,
isActive: true,
},
})
if (!user) return null
// Gate 2: inactive accounts blocked at login — no JWT issued
// Same null return — attacker cannot distinguish disabled from wrong password
if (!user.isActive) return null
// Gate 3: password verify (12-round hash set at account creation)
const passwordValid = await bcrypt.compare(credentials.password, user.hashedPassword)
if (!passwordValid) return null
// Gate 4: return shape MUST match augmented User interface
return {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
}
}
Why inactive blocking belongs in authorize(), not proxy.ts
proxy.ts runs on every authenticated request and only reads the signed JWT—it cannot cheaply re-query isActive without a database hit per page view. authorize() runs once at login with full Prisma access. Block inactive users there and they never receive a thirty-day token. Admins disable accounts through /admin/users or an npm run create-user CLI script; the next login attempt fails with the same generic error as a typo.
Account creation never touches public signup forms. Admins use /admin/users to provision username, email, temporary password, and role—or run a CLI script during local development. Passwords hash with bcryptjs at twelve rounds stored in a constants file, matching the third-party credential encryption pattern elsewhere in the platform: sensitive values get hardened at write time, verified at read time, never logged in plaintext.
Return null from authorize() for all failure cases to avoid username enumeration. If you throw a specific error for account disabled versus wrong password, an attacker can enumerate valid usernames by observing which error they get. Null collapses all failures into one generic message.
proxy.ts: The Next.js 16 Successor to middleware.ts for Route Protection
In Next.js 16 with NextAuth v5, route protection moves from middleware.ts at the project root to src/proxy.ts—a file referenced in next.config.ts that wraps NextAuth's auth() as the middleware handler.
How proxy.ts replaces middleware.ts
v4 exported default middleware from next-auth/middleware and wrapped role logic with withAuth(). v5 imports auth() from your config, calls it inside a custom proxy function, inspects session.user.role, and returns NextResponse redirects. The file lives under src/, not the project root, and Next.js 16 wires it through next.config.ts rather than automatic middleware.ts detection. Public routes include /, /login, /api/auth/*, and /go/*—the click-tracking redirect from my link tracker post must stay unauthenticated.
// src/proxy.ts
import { auth } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'
const PUBLIC_ROUTES = ['/', '/login', '/api/auth', '/go/'] as const
const ADMIN_ROUTES = ['/admin'] as const
function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.some((route) =>
route === '/' ? pathname === '/' : pathname.startsWith(route)
)
}
function isAdminRoute(pathname: string): boolean {
return ADMIN_ROUTES.some((route) => pathname.startsWith(route))
}
/**
* NextAuth v5 route protection — replaces root middleware.ts + withAuth().
* auth() replaces getServerSession(authOptions) from v4.
*/
export default async function proxy(request: NextRequest) {
const session = await auth()
const { pathname } = request.nextUrl
// Public routes — including /go/* click redirects (no session required)
if (isPublicRoute(pathname)) {
return NextResponse.next()
}
// Unauthenticated — redirect to login with return path
if (!session?.user) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// Admin routes require admin role — redirect to dashboard, not 403
if (isAdminRoute(pathname) && session.user.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
/*
* next.config.ts wiring (Next.js 16 + NextAuth v5):
*
* const nextConfig = {
* experimental: {
* proxyHandler: './src/proxy.ts',
* },
* }
* export default nextConfig
*/
Wiring proxy.ts into next.config.ts
Unlike middleware.ts, proxy.ts is not auto-discovered. Reference it in next.config.ts through the experimental proxyHandler path—or the equivalent instrumentation entry your Next.js 16 version documents. Missing this wiring produces a confusing symptom: auth() works in Server Components but routes never redirect. I verified the matcher excludes static assets and lets /api/auth/* through so NextAuth's own handlers stay reachable.
The redirect targets matter for UX. Unauthenticated users land on /login with a callbackUrl query param so they return to the page they requested after signing in. Wrong-role users redirect to /dashboard—not HTTP 403—because affiliate marketers on the platform already understand dashboard as home base. A forbidden page feels like a broken product; a dashboard redirect feels like permissions working as designed.
Defence in Depth: Role Checks in Server Components as a Second Layer
proxy.ts blocks unauthorised route access at the middleware layer, but a role check inside the Server Component itself adds a second enforcement layer that survives middleware configuration changes or future route additions.
The admin page role check pattern
/admin/users is a React Server Component listing provisioned accounts. Before any Prisma query runs, I call auth() and redirect non-admins:
import { auth } from '@/lib/auth' and import { redirect } from 'next/navigation' — then at the top of the default export: const session = await auth(), followed by if (!session || session.user.role !== 'admin') redirect('/dashboard'). Only after that guard do user list queries execute. This is not redundant—it is defence in depth. proxy.ts is configuration; component checks are code. If someone adds /admin/settings and forgets to update ADMIN_ROUTES, the component check still catches it.
The /admin/users page lists every provisioned account with role badges, last login timestamps, and isActive toggles. Server Actions behind each row call auth() again before updateUserRole or deactivateUser mutations fire. Middleware cannot protect Server Actions invoked directly—only the action's own auth() guard can. That is why I treat proxy.ts as layer one and action-level checks as layer three for write operations, even though this post focuses on the read-path component pattern.
What auth() returns in different contexts
One genuine v5 improvement: auth() from src/lib/auth.ts works identically in React Server Components, Server Actions marked 'use server', and Route Handlers under app/api. v4 required getServerSession(authOptions) with authOptions imported separately into each context. Server Actions creating users or toggling isActive call the same auth() and reject non-admin callers before touching Prisma—mirroring the AES-256-GCM credential encryption pattern where sensitive operations double-check identity at the action layer.
Import auth() from your own src/lib/auth.ts export—not directly from 'next-auth'. Your export is pre-configured with callbacks that populate role. Importing from 'next-auth' directly bypasses your jwt() and session() wiring and returns an unconfigured instance missing custom session fields.
NextAuth v4 vs v5: The Differences That Matter for Role-Based Access Control
The five changes between NextAuth v4 and v5 that affect RBAC are middleware location, session callback requirements, host trust config, the session getter API, and authorize() return type strictness.
| NextAuth v4 | NextAuth v5 | |
|---|---|---|
| Route protection file | middleware.ts (project root, auto-detected) | src/proxy.ts (referenced in next.config.ts) |
| Route protection pattern | export { default } from 'next-auth/middleware' + withAuth() | Import auth(), call inside proxy function, apply custom logic |
| Get session in Server Component | getServerSession(authOptions) from 'next-auth/next' | auth() from your own auth config export |
| Get session in Server Action | getServerSession(authOptions) | auth() — same API, same import |
| Custom session fields | Added via callbacks, typed via module augmentation | Same — but silent drop without jwt() + session() callbacks is more common in v5 |
| Host trust for production | NEXTAUTH_URL environment variable | trustHost: true in config OR AUTH_TRUST_HOST env var |
| authorize() return type | Loosely typed User | Must match augmented User interface — TypeScript error if shape is wrong |
| Session strategy default | Database (with adapter) or JWT | JWT — explicit session: { strategy: 'jwt' } still recommended for clarity |
The frustrating thing about NextAuth v5 isn't what it broke — it's what it broke silently. Role disappears from the session without a console warning. The middleware file moves without a deprecation error. trustHost missing causes a vague Vercel deployment failure. Every one of these is a 30-minute debugging session the first time. None of them is hard to fix once you know what to look for.
Two trade-offs I accept on the platform: JWT sessions mean no real-time revocation—marking a user inactive after login does not invalidate their existing thirty-day token until expiry—and role changes require re-login because role lives inside the signed JWT. For an invite-only SaaS with admin-managed accounts, that pragmatism beats database session lookups on every request. When real-time revocation becomes mandatory, I would switch session strategy or force token refresh on role change—not patch around stale JWTs in proxy.ts.
If an admin promotes a user from user to admin in Postgres, the promotion does not take effect until that user signs out and back in. The JWT still carries the old role string. Same problem in reverse: demoting an admin leaves elevated privileges in the token until expiry. Document this behavior in your runbooks so support teams do not assume database updates instantly change access. Shorter maxAge for admin tokens, database sessions, or explicit session invalidation on role mutation are the three real fixes—each with its own latency and complexity cost.
Frequently Asked Questions
Extend NextAuth v5 User and Session interfaces with a role union type, then copy it through authorize(), jwt(), and session() callbacks. Return role from Credentials authorize() alongside id, email, and username. The jwt() callback must copy user.role into token.role on sign-in; the session() callback must copy token.role onto session.user. Without both callbacks, v5 silently drops role from session.user even though authorize() succeeded. Hassan Raza exposes role via auth() in Server Components, Server Actions, and Route Handlers—one import from src/lib/auth.ts, four session fields total.
NextAuth v5 replaces root middleware.ts with src/proxy.ts wired through next.config.ts instead of auto-detection. V4 exported NextAuth default middleware plus withAuth(); v5 imports auth() from your config and calls it inside a custom proxy function to read session.user.role before redirecting. getServerSession(authOptions) becomes auth() across Server Components, Server Actions, and Route Handlers. Production deploys require trustHost: true in config or AUTH_TRUST_HOST—Vercel fails without it. Hassan Raza documents five v4→v5 migration shifts on hassanr.com; middleware relocation and silent callback drops cause the longest debugging sessions.
Protect routes with two layers: proxy.ts redirects non-admins from /admin/*, then Server Components call auth() and redirect mismatched roles. proxy.ts checks session.user.role at the edge and redirects non-admins from /admin/* to /dashboard before render; Server Components call auth() and repeat the check so new admin pages stay safe if proxy.ts routes lag behind. Redirect to /dashboard—not HTTP 403—matches SaaS UX expectations. Hassan Raza stores role inside thirty-day JWTs, so database role changes need re-login until tokens expire; database sessions fix real-time sync but add query overhead each request.