Authentication: Complete Auth.js (NextAuth v5) Guide
The Genuine Complexity of Authentication
Authentication looks simple and almost always turns out to be the most error-prone part of an application. You have to handle session management, password hashing, OAuth flows, CSRF protection, role-based access, session invalidation โ and every detail matters. Auth.js (NextAuth v5) is not just about convenience; it packages the correct implementation of all these details into a library that has been audited by the community.
NextAuth v5 is a significant architectural rethink of v4, designed specifically for the Next.js App Router. The most important change is a split configuration: one file for the Edge Runtime (used by Middleware) and one for the full Node.js environment (Server Components, API Routes). Understanding this split is the foundation for using Auth.js v5 effectively.
Why the Configuration Must Be Split
Next.js Middleware runs in the Edge Runtime โ a lean V8-based environment that does not support Node.js built-in modules (crypto, fs) or many npm packages (bcrypt). The Edge Runtime's advantage is near-zero cold start and the ability to execute at CDN edge nodes, making it ideal for route-level authentication checks.
But full authentication logic โ especially password verification in the Credentials provider โ depends on bcrypt, a Node.js native addon that cannot run on the Edge. The solution is configuration splitting:
auth.config.ts: lightweight config without Credentials logic, safe for the Edgeauth.ts: full config including Credentials provider, only runs in Node.js
Complete Configuration
npm install next-auth@beta @auth/prisma-adapter bcryptjs
npm install -D @types/bcryptjs
// auth.config.ts โ Edge-compatible configuration
import type { NextAuthConfig } from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
export const authConfig: NextAuthConfig = {
pages: {
signIn: '/login',
error: '/login',
},
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isProtected = nextUrl.pathname.startsWith('/dashboard')
if (isProtected) return isLoggedIn
return true
},
jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role
}
return token
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
},
},
}
// auth.ts โ Full configuration (Node.js only)
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
import { authConfig } from './auth.config'
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
providers: [
...authConfig.providers,
Credentials({
async authorize(credentials) {
const { email, password } = credentials as {
email: string
password: string
}
if (!email || !password) return null
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
password: true,
role: true,
},
})
if (!user || !user.password) return null
const passwordMatch = await bcrypt.compare(password, user.password)
if (!passwordMatch) return null
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
})
The choice of session: { strategy: 'jwt' } deserves explanation. The database session strategy (the default) queries the database on every request to verify the session. The JWT strategy encodes session information in a signed token, eliminating those queries โ which is valuable for high-concurrency and serverless deployments. The trade-off is that token revocation requires extra infrastructure (a blacklist or short token lifetimes).
API Route and Middleware Setup
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// middleware.ts โ uses the Edge-compatible authConfig
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
export const { auth: middleware } = NextAuth(authConfig)
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Middleware uses authConfig (without Credentials) so it can safely run on the Edge. The authorized callback executes on every matched request โ this is route-level protection that fires before any page begins rendering.
Extending the Session Type
Auth.js's default session.user type only includes name, email, and image. We added id and role, so we need a type declaration:
// types/next-auth.d.ts
import type { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
role: string
} & DefaultSession['user']
}
}
This uses TypeScript Module Augmentation. After this declaration, session.user.id and session.user.role have full type support everywhere in the codebase.
Auth in Server Components
// 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('/login')
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Your role: {session.user.role}</p>
</div>
)
}
auth() is called directly in the Server Component and reads the session from the current request. No API call, no useSession hook needed. This is one of the major advantages of the App Router: authentication information is available during server rendering, with no client-side hydration required before the auth check happens.
Login Form and Server Actions
// app/login/actions.ts
'use server'
import { signIn } from '@/auth'
import { AuthError } from 'next-auth'
export async function loginWithCredentials(formData: FormData) {
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: '/dashboard',
})
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return { error: 'Invalid email or password' }
default:
return { error: 'Login failed, please try again' }
}
}
throw error // Re-throw redirect and other non-error exceptions
}
}
// app/login/page.tsx
'use client'
import { useActionState } from 'react'
import { loginWithCredentials } from './actions'
export default function LoginPage() {
const [state, action, isPending] = useActionState(loginWithCredentials, undefined)
return (
<form action={action} className="space-y-4">
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
</div>
{state?.error && (
<p className="text-red-500">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}
A subtle but important point: signIn throws a redirect exception on success (an internal Next.js mechanism). The catch block must re-throw any non-AuthError exceptions, or the redirect will be swallowed and the user will never navigate to /dashboard.
OAuth Login
// OAuth buttons as Server Actions
async function loginWithGitHub() {
'use server'
await signIn('github', { redirectTo: '/dashboard' })
}
async function loginWithGoogle() {
'use server'
await signIn('google', { redirectTo: '/dashboard' })
}
// In JSX
<form action={loginWithGitHub}>
<button type="submit">Sign in with GitHub</button>
</form>
<form action={loginWithGoogle}>
<button type="submit">Sign in with Google</button>
</form>
Auth in Client Components
Sometimes a Client Component needs to know the current user (for conditional UI rendering):
// app/layout.tsx โ provide session to client
import { SessionProvider } from 'next-auth/react'
import { auth } from '@/auth'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
return (
<html>
<body>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
)
}
// components/UserMenu.tsx
'use client'
import { useSession, signOut } from 'next-auth/react'
export function UserMenu() {
const { data: session, status } = useSession()
if (status === 'loading') return <div>Loading...</div>
if (!session) return null
return (
<div>
<span>{session.user.name}</span>
<button onClick={() => signOut({ callbackUrl: '/' })}>
Sign out
</button>
</div>
)
}
SessionProvider is initialized in the Layout (as a Server Component), passing the server-fetched session to the client. This avoids an extra network request from the client. useSession then consumes this session in Client Components.
Role-Based Access Control
Fine-grained route protection in the Middleware authorized callback:
// auth.config.ts โ expanded authorized callback
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const userRole = (auth?.user as any)?.role
// Admin routes
if (nextUrl.pathname.startsWith('/admin')) {
return isLoggedIn && userRole === 'ADMIN'
}
// Authenticated user routes
if (nextUrl.pathname.startsWith('/dashboard')) {
return isLoggedIn
}
// Redirect logged-in users away from the login page
if (nextUrl.pathname === '/login' && isLoggedIn) {
return Response.redirect(new URL('/dashboard', nextUrl))
}
return true
},
},
Protecting Server Actions
Middleware protects routes, but Server Actions can be called from anywhere. Server Actions must perform their own permission checks:
// app/admin/actions.ts
'use server'
import { auth } from '@/auth'
export async function deleteUser(userId: string) {
const session = await auth()
// Never trust the client โ Server Actions must verify independently
if (!session || session.user.role !== 'ADMIN') {
throw new Error('Forbidden')
}
await prisma.user.delete({ where: { id: userId } })
revalidatePath('/admin/users')
}
This is a fundamental principle of secure programming: validate at every layer. Middleware is the first line of defense (blocking unauthorized route access). Server Action checks are the second (preventing direct invocations that bypass route-level protection).
User Registration
Registration falls outside Auth.js's scope โ it handles authentication, not account creation. Implement it yourself:
// app/register/actions.ts
'use server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
import { signIn } from '@/auth'
export async function registerUser(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const name = formData.get('name') as string
if (!email || !password || password.length < 8) {
return { error: 'Please check your input' }
}
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) return { error: 'Email already registered' }
// Cost factor 12 is the sweet spot between security and performance
const hashedPassword = await bcrypt.hash(password, 12)
await prisma.user.create({
data: { email, name, password: hashedPassword },
})
// Automatically sign in after successful registration
await signIn('credentials', { email, password, redirectTo: '/dashboard' })
}
The bcrypt cost factor (12 here) determines how long the hashing takes. At cost 12, hashing takes roughly 250ms on modern hardware โ imperceptible to a user, but devastating for brute-force attackers who are limited to about 4 attempts per second. This is bcrypt's core design: an adaptive cost that you can increase as hardware improves.
Configuration Summary
A complete Auth.js v5 setup in Next.js 15 spans four files:
auth.config.ts: Edge-compatible base config (callbacks, pages, OAuth providers)auth.ts: Full config, extendsauthConfigand adds Credentials and the Prisma adaptermiddleware.ts: Lightweight auth guard usingauthConfigtypes/next-auth.d.ts: Session type augmentation
Each file has a clear responsibility following the principle of least privilege โ code running on the Edge only includes Edge-compatible dependencies, and Node.js-specific logic (bcrypt, Prisma adapter) only loads in Node.js environments.