Next.js Authentication — NextAuth.js v5 Complete Guide for 2026

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Authentication is one of the most error-prone parts of any web application. Rolling your own auth means dealing with password hashing, session management, CSRF protection, and token rotation — all of which have well-known failure modes. NextAuth.js v5 (Auth.js) solves these problems with a framework-native API that integrates seamlessly with the App Router.

NextAuth.js v5 is a ground-up rewrite of v4. It introduces a unified auth() function that works in Server Components, Route Handlers, Server Actions, and middleware — replacing the fragmented getServerSession / useSession split.

Installing and Configuring NextAuth.js v5

npm install next-auth@beta

Create the main auth config file:

// auth.ts (root of project)
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
    Google({
      clientId: process.env.GOOGLE_ID!,
      clientSecret: process.env.GOOGLE_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 prisma.user.findUnique({
          where: { email: credentials.email as string },
        })
 
        if (!user || !user.password) return null
 
        const valid = await bcrypt.compare(
          credentials.password as string,
          user.password
        )
 
        return valid ? { id: user.id, email: user.email, name: user.name } : null
      },
    }),
  ],
  pages: {
    signIn: '/login',
    error: '/auth/error',
  },
  callbacks: {
    async session({ session, token }) {
      if (token?.sub) session.user.id = token.sub
      return session
    },
  },
})

Mount the catch-all route handler:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers

Required environment variables:

AUTH_SECRET=your_32_char_random_secret
GITHUB_ID=your_github_app_client_id
GITHUB_SECRET=your_github_app_client_secret
GOOGLE_ID=your_google_client_id
GOOGLE_SECRET=your_google_client_secret
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb

Protecting Routes with Middleware

The most scalable way to protect routes is middleware, which runs on the Vercel Edge Network before any page renders:

// middleware.ts
import { auth } from '@/auth'
 
export default auth((req) => {
  const isLoggedIn = !!req.auth
  const isAuthRoute = req.nextUrl.pathname.startsWith('/auth')
  const isProtected = req.nextUrl.pathname.startsWith('/dashboard') ||
                      req.nextUrl.pathname.startsWith('/admin')
 
  if (isProtected && !isLoggedIn) {
    const loginUrl = new URL('/login', req.nextUrl)
    loginUrl.searchParams.set('callbackUrl', req.nextUrl.pathname)
    return Response.redirect(loginUrl)
  }
 
  if (isAuthRoute && isLoggedIn) {
    return Response.redirect(new URL('/dashboard', req.nextUrl))
  }
})
 
export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*', '/auth/:path*'],
}

Using the Session in Server Components

The auth() function is async and works natively 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?.user) {
    redirect('/login')
  }
 
  return (
    <main>
      <h1>Welcome back, {session.user.name}</h1>
      <p>Email: {session.user.email}</p>
    </main>
  )
}

Building the Login Page

// app/login/page.tsx
'use client'
 
import { signIn } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
 
export default function LoginPage() {
  const router = useRouter()
  const searchParams = useSearchParams()
  const callbackUrl = searchParams.get('callbackUrl') ?? '/dashboard'
 
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
 
  async function handleCredentials(e: React.FormEvent) {
    e.preventDefault()
    const result = await signIn('credentials', {
      email,
      password,
      redirect: false,
    })
 
    if (result?.error) {
      setError('Invalid email or password')
      return
    }
 
    router.push(callbackUrl)
  }
 
  return (
    <div className="max-w-md mx-auto mt-16 p-6 border rounded-lg">
      <h1 className="text-2xl font-bold mb-6">Sign in</h1>
 
      <form onSubmit={handleCredentials} className="space-y-4 mb-6">
        {error && <p className="text-red-600 text-sm">{error}</p>}
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Email"
          required
          className="w-full border p-2 rounded"
        />
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="Password"
          required
          className="w-full border p-2 rounded"
        />
        <button type="submit" className="w-full bg-blue-600 text-white py-2 rounded">
          Sign in with Email
        </button>
      </form>
 
      <div className="space-y-2">
        <button
          onClick={() => signIn('github', { callbackUrl })}
          className="w-full bg-gray-900 text-white py-2 rounded"
        >
          Sign in with GitHub
        </button>
        <button
          onClick={() => signIn('google', { callbackUrl })}
          className="w-full bg-red-500 text-white py-2 rounded"
        >
          Sign in with Google
        </button>
      </div>
    </div>
  )
}

User Registration API

// app/api/register/route.ts
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
 
export async function POST(request: NextRequest) {
  const { email, password, name } = await request.json()
 
  if (!email || !password || password.length < 8) {
    return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
  }
 
  const existing = await prisma.user.findUnique({ where: { email } })
  if (existing) {
    return NextResponse.json({ error: 'User already exists' }, { status: 400 })
  }
 
  const hashed = await bcrypt.hash(password, 12)
 
  const user = await prisma.user.create({
    data: { email, name, password: hashed },
    select: { id: true, email: true, name: true },
  })
 
  return NextResponse.json(user, { status: 201 })
}
// auth.ts — add Email provider
import Email from 'next-auth/providers/nodemailer'
 
providers: [
  Email({
    server: process.env.EMAIL_SERVER,   // smtp://user:pass@smtp.example.com:587
    from: process.env.EMAIL_FROM,       // noreply@example.com
  }),
]

NextAuth.js sends a one-time link to the user's email. No passwords to store or rotate.

Common Mistakes

  • Forgetting to set AUTH_SECRET in production — NextAuth.js will throw a cryptic error at startup
  • Using getServerSession from NextAuth v4 in an App Router project — use auth() from v5 instead
  • Not adding the Prisma adapter when using OAuth providers — without it, users cannot be persisted across sessions
  • Storing sensitive data like roles directly in the JWT without validating on the server — always re-fetch from the database for privileged actions
  • Redirecting to an open URL from callbackUrl without validating the origin — always validate the callback URL is within your own domain

Best Practices

  • Store user roles in the database, not the JWT; read them from the session callback on each request
  • Use bcrypt with a cost factor of 12 or higher for password hashing
  • Enable HTTPS-only cookies in production by setting useSecureCookies: true (default when AUTH_URL is HTTPS)
  • Implement rate limiting on /api/register and /api/auth/callback/credentials to prevent brute force attacks
  • Use the Prisma adapter with database sessions for admin dashboards where revocation matters

Key Takeaways

  • NextAuth.js v5 ships a unified auth() function that works in Server Components, middleware, Server Actions, and Route Handlers
  • OAuth providers (GitHub, Google) require creating an OAuth app in each platform and setting client ID/secret as environment variables
  • Middleware-based route protection runs at the edge and is more efficient than per-page redirects
  • The Prisma adapter persists OAuth accounts and sessions to your database automatically
  • bcrypt with cost factor 12 is the minimum recommended for password hashing in 2026
  • Magic links (passwordless) via the Email provider eliminate password storage entirely
  • Always validate callbackUrl to prevent open redirect vulnerabilities
  • Credentials provider requires a custom authorize function; it does not auto-create users

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading