JWT vs database sessions, OAuth and credentials providers, auth() in Server Components and Actions, middleware-based route protection, RBAC, and the new forbidden()/unauthorized() auth interrupt system.
P-3 — Authentication with Auth.js (NextAuth v5)
Who this is for: Practitioners building applications that require user identity — login, session management, route protection, and role-based access. Auth.js v5 (formerly NextAuth) was rebuilt from scratch for the App Router and Next.js 15. If you've used NextAuth v4 before, almost everything changed. If you're starting fresh, this is the current standard.
What Changed in v5
NextAuth v4 was designed for the Pages Router. It worked in the App Router but with friction — getServerSession() had to be called with the same config object everywhere, the session wasn't naturally available in Server Components, and middleware integration was awkward.
Auth.js v5 was rewritten for the App Router with three design goals:
- A single
auth()call that works everywhere — Server Components, Route Handlers, Server Actions, and Middleware — without passing the config object around. - First-class edge runtime support. The middleware-based session check runs at the CDN edge with near-zero latency, not in a full Node.js runtime.
- Framework agnostic core. Auth.js v5 works with Next.js, SvelteKit, and other frameworks from the same package (
next-authis a re-export of@auth/nextjs).
The migration from v4 to v5 is significant — different import paths, different config structure, different session API. This module covers v5 exclusively.
Installation and Setup
bashnpm install next-auth@beta
Auth.js v5 uses the @beta tag as of late 2025 — it's stable and used in production, but the version tag reflects active development. Check npm show next-auth dist-tags for the current recommendation.
Create the core auth config:
ts// auth.ts (at the project root, alongside next.config.ts) import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; import Credentials from 'next-auth/providers/credentials'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET, }), 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.users.findUnique({ where: { email: credentials.email as string }, }); if (!user || !user.passwordHash) return null; const valid = await bcrypt.compare( credentials.password as string, user.passwordHash ); if (!valid) return null; return { id: user.id, email: user.email, name: user.name, role: user.role }; }, }), ], callbacks: { jwt({ token, user }) { // Persist the role in the JWT on first sign-in if (user) token.role = (user as any).role; return token; }, session({ session, token }) { // Make role available in session.user if (token.role) session.user.role = token.role as string; return session; }, }, pages: { signIn: '/auth/login', error: '/auth/error', }, });
The four exports are the entire Auth.js API surface:
handlers— theGETandPOSTRoute Handlers for OAuth callbacks and API endpointsauth— the session retrieval function (works everywhere)signIn— programmatic sign-in (Server Actions)signOut— programmatic sign-out (Server Actions)
The Route Handler
Auth.js needs a [...nextauth] catch-all route to handle OAuth redirects, callbacks, and the sign-in/sign-out API:
ts// app/api/auth/[...nextauth]/route.ts import { handlers } from '@/auth'; export const { GET, POST } = handlers;
That's the entire file. handlers contains the fully configured GET and POST functions.
Reading the Session
The auth() function retrieves the current session. It works identically in every context:
Server Component:
tsx// app/dashboard/page.tsx import { auth } from '@/auth'; import { redirect } from 'next/navigation'; export default async function DashboardPage() { const session = await auth(); if (!session) redirect('/auth/login'); return <h1>Welcome, {session.user.name}</h1>; }
Server Action:
ts// app/actions/posts.ts 'use server'; import { auth } from '@/auth'; export async function createPost(formData: FormData) { const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); // session.user.id, session.user.email, session.user.role are available await db.posts.create({ data: { title: formData.get('title') as string, authorId: session.user.id, }, }); }
Route Handler:
ts// app/api/profile/route.ts import { auth } from '@/auth'; export async function GET() { const session = await auth(); if (!session) return new Response('Unauthorized', { status: 401 }); return Response.json(session.user); }
One function, same interface everywhere. No passing config objects, no getServerSession(authOptions) calls.
Session Strategies: JWT vs Database
Auth.js supports two session storage strategies:
JWT (default): Session data is stored in an encrypted cookie. No database reads on every request — the session is decoded from the cookie. Fast, works at the edge, stateless.
Database sessions: Session data is stored in a database. The cookie contains only a session ID. Every auth() call queries the database. Supports instant session revocation (delete the row, user is logged out immediately). Requires an Auth.js database adapter.
ts// JWT strategy (default) — no additional config needed // Database strategy export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), // from @auth/prisma-adapter session: { strategy: 'database' }, providers: [...], });
When to use JWT: Personal projects, most SaaS applications, applications where session invalidation latency is acceptable (the JWT expires naturally).
When to use database sessions: Applications with strict security requirements (banking, healthcare), when you need instant logout across all devices, when you need to store large amounts of session data that would exceed cookie size limits.
For most applications, JWT is the right choice.
Extending the Session Type
TypeScript will complain that session.user.role doesn't exist on the default Session type. Fix this by extending the Auth.js types:
ts// types/next-auth.d.ts import { DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session { user: { id: string; role: string; } & DefaultSession['user']; } }
This augments the Auth.js types globally so session.user.id and session.user.role are typed correctly throughout the application.
Middleware Route Protection — The Edge Layer
Middleware runs at the CDN edge before every request — before the page renders, before Server Components execute, before any Route Handlers run. This makes it the right place for session-based redirects: the check is cheap (JWT decryption, no database), happens as early as possible, and never renders a page that the user shouldn't see.
ts// middleware.ts (at the project root) import { auth } from '@/auth'; import { NextResponse } from 'next/server'; export default auth((request) => { const { nextUrl, auth: session } = request; const isLoggedIn = !!session; const isAuthPage = nextUrl.pathname.startsWith('/auth'); const isDashboard = nextUrl.pathname.startsWith('/dashboard'); const isAdmin = nextUrl.pathname.startsWith('/admin'); // Redirect unauthenticated users away from protected routes if (isDashboard && !isLoggedIn) { return NextResponse.redirect(new URL('/auth/login', nextUrl)); } // Redirect authenticated users away from auth pages if (isAuthPage && isLoggedIn) { return NextResponse.redirect(new URL('/dashboard', nextUrl)); } // Role-based access: admin routes require admin role if (isAdmin && session?.user?.role !== 'admin') { return NextResponse.redirect(new URL('/unauthorized', nextUrl)); } return NextResponse.next(); }); export const config = { matcher: [ '/((?!api/auth|_next/static|_next/image|favicon.ico).*)', ], };
Auth.js v5 wraps your middleware function with session decoding — request.auth contains the session (or null) without you writing any JWT decryption code.
The matcher controls which routes trigger the middleware. The pattern above skips static files, images, and the auth API routes themselves (which must be accessible without a session). Always be specific with matchers — running auth middleware on every static file request wastes compute.
forbidden() and unauthorized() — Auth Interrupts (Next.js 15)
Next.js 15 introduced two new control-flow functions specifically for auth scenarios:
unauthorized()— the user is not authenticated (401). Renders the nearestunauthorized.tsx.forbidden()— the user is authenticated but lacks permission (403). Renders the nearestforbidden.tsx.
tsx// app/admin/page.tsx import { auth } from '@/auth'; import { unauthorized, forbidden } from 'next/navigation'; export default async function AdminPage() { const session = await auth(); if (!session) unauthorized(); // → renders app/unauthorized.tsx if (session.user.role !== 'admin') forbidden(); // → renders app/forbidden.tsx return <AdminDashboard />; }
tsx// app/unauthorized.tsx import Link from 'next/link'; export default function UnauthorizedPage() { return ( <div className="text-center py-24"> <h1 className="text-2xl font-semibold mb-2">Sign in required</h1> <p className="text-zinc-400 mb-6">You need to be signed in to access this page.</p> <Link href="/auth/login" className="bg-white text-black px-4 py-2 rounded"> Sign In </Link> </div> ); }
tsx// app/forbidden.tsx export default function ForbiddenPage() { return ( <div className="text-center py-24"> <h1 className="text-2xl font-semibold mb-2">Access Denied</h1> <p className="text-zinc-400">You don't have permission to access this page.</p> </div> ); }
Before unauthorized() and forbidden(), the pattern was redirect('/login') for both cases. The new functions are semantically correct (401 vs 403 status codes), and they render dedicated pages rather than redirecting — which means the user sees the right message rather than landing on a generic login page when they're already logged in but lack permission.
Sign-In and Sign-Out
Sign-in page:
tsx// app/auth/login/page.tsx import { signIn } from '@/auth'; export default function LoginPage() { return ( <div className="max-w-sm mx-auto mt-24 space-y-4"> {/* OAuth providers */} <form action={async () => { 'use server'; await signIn('github', { redirectTo: '/dashboard' }); }} > <button type="submit" className="w-full bg-zinc-800 py-2 rounded"> Sign in with GitHub </button> </form> {/* Credentials */} <form action={async (formData) => { 'use server'; await signIn('credentials', { email: formData.get('email'), password: formData.get('password'), redirectTo: '/dashboard', }); }} > <input name="email" type="email" placeholder="Email" className="w-full border rounded px-3 py-2" /> <input name="password" type="password" placeholder="Password" className="w-full border rounded px-3 py-2" /> <button type="submit" className="w-full bg-white text-black py-2 rounded"> Sign in </button> </form> </div> ); }
Sign-out:
tsx// components/SignOutButton.tsx import { signOut } from '@/auth'; export default function SignOutButton() { return ( <form action={async () => { 'use server'; await signOut({ redirectTo: '/' }); }} > <button type="submit">Sign out</button> </form> ); }
Both signIn and signOut are Server Actions — they handle the redirect internally. No router.push(), no manual cookie clearing.
RBAC — Role-Based Access Control
With role in the JWT (set up in the callbacks above), RBAC flows naturally:
tsx// Middleware: protect entire routes by role if (isAdmin && session?.user?.role !== 'admin') { return NextResponse.redirect(new URL('/forbidden', nextUrl)); } // Server Component: conditional rendering based on role const session = await auth(); if (session?.user?.role === 'admin') { return <AdminControls />; } // Server Action: verify role before executing const session = await auth(); if (session?.user?.role !== 'editor' && session?.user?.role !== 'admin') { throw new Error('Forbidden'); }
For more sophisticated RBAC (hierarchical roles, permission sets, resource-level access), consider abstracting the checks:
ts// lib/permissions.ts type Role = 'admin' | 'editor' | 'viewer'; type Resource = 'posts' | 'users' | 'settings'; type Action = 'create' | 'read' | 'update' | 'delete'; const permissions: Record<Role, Record<Resource, Action[]>> = { admin: { posts: ['create', 'read', 'update', 'delete'], users: ['create', 'read', 'update', 'delete'], settings: ['read', 'update'], }, editor: { posts: ['create', 'read', 'update'], users: ['read'], settings: ['read'], }, viewer: { posts: ['read'], users: [], settings: [], }, }; export function can(role: Role, resource: Resource, action: Action): boolean { return permissions[role]?.[resource]?.includes(action) ?? false; }
ts// In a Server Action const session = await auth(); const role = session?.user?.role as Role; if (!can(role, 'posts', 'delete')) throw new Error('Forbidden');
Environment Variables
Auth.js requires specific environment variables:
bash# .env.local # Required: secret for JWT/cookie encryption AUTH_SECRET=your-random-secret-here # openssl rand -base64 32 # OAuth providers (if used) AUTH_GITHUB_ID=your-github-client-id AUTH_GITHUB_SECRET=your-github-client-secret # Auth.js v5 automatically uses AUTH_* prefix for providers # No AUTH_URL needed in most deployments (auto-detected from request headers)
AUTH_SECRET is the only hard requirement. Generate it with openssl rand -base64 32 and never commit it. On Vercel and most deployment platforms, set it in the environment variables dashboard, not in .env files.
Where We Go From Here
P-4 covers database integration with Prisma — setting up the schema for the user/session tables Auth.js needs (if using database sessions), the critical connection pooling issue that kills serverless applications at scale, and the full mutation pattern that connects Prisma to Server Actions.
The auth patterns from this module are the prerequisite for everything in the Architect phase that involves user-specific data — personalised pages, user-scoped caching, tenant isolation in multi-zone architectures.