Authentication with Auth.js (NextAuth v5)12 min read
Module P-4·12 min read
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.
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.
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 is built on a shared, framework-agnostic core (@auth/core), with a separate package per framework: next-auth for Next.js, @auth/sveltekit for SvelteKit, @auth/express for Express, and so on. Next.js's own integration is the next-auth package itself, not a re-export of some other Next.js-specific package.
The migration from v4 to v5 is significant — different import paths, different config structure, different session API. This module covers v5 exclusively.
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:
// 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';
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 — the GET and POST Route Handlers for OAuth callbacks and API endpoints
auth — the session retrieval function (works everywhere)
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.
// JWT strategy (default) — no additional config needed
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.
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.
// middleware.ts (at the project root)
import { auth } from '@/auth'; import { NextResponse } from 'next/server';
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.
if (!session) unauthorized(); // → renders app/unauthorized.tsx if (session.user.role !== 'admin') forbidden(); // → renders app/forbidden.tsx
return <AdminDashboard />; }
// 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> ); }
// 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.
With role in the JWT (set up in the callbacks above), RBAC flows naturally:
// 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:
// lib/permissions.ts
type Role = 'admin' | 'editor' | 'viewer'; type Resource = 'posts' | 'users' | 'settings'; type Action = 'create' | 'read' | 'update' | 'delete';
// In a Server Action const session = await auth(); const role = session?.user?.role as Role; if (!can(role, 'posts', 'delete')) throw new Error('Forbidden');
# 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.
P-5 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.
Knowledge Check
What is one of the key design goals and improvements in Auth.js v5 compared to NextAuth v4?
Which of the following is true about session storage strategies in Auth.js?
What are the two new control-flow functions introduced in Next.js 15 for handling auth scenarios semantically?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.