Chapter 22

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:

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:

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.

Rate this chapter
4.5  / 5  (7 ratings)

๐Ÿ’ฌ Comments