Authentication Guide 2026 — NextAuth v5, Clerk, and JWT Best Practices
Advertisement
Introduction
Why This Matters
Authentication is the most security-critical part of any web application. NextAuth v5 (Auth.js) redesigns the API around the App Router and Edge runtime. Getting it right from day one prevents account takeover vulnerabilities that are difficult to fix after launch.
NextAuth v5 Setup
npm install next-auth@beta @auth/prisma-adapter// auth.ts (project root)
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
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) {
const parsed = z.object({
email: z.string().email(),
password: z.string().min(8),
}).safeParse(credentials)
if (!parsed.success) return null
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
})
if (!user?.passwordHash) return null
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash)
if (!valid) return null
return { id: user.id, email: user.email, name: user.name }
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role ?? 'USER'
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
},
},
pages: {
signIn: '/auth/login',
error: '/auth/error',
},
})Route Handler Integration
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlersMiddleware-Based Route Protection
// middleware.ts (project root)
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export default auth((request) => {
const { pathname } = request.nextUrl
const isAuthenticated = !!request.auth
// Protected routes
if (pathname.startsWith('/dashboard') && !isAuthenticated) {
return NextResponse.redirect(new URL('/auth/login', request.url))
}
// Admin-only routes
if (pathname.startsWith('/admin')) {
if (!isAuthenticated) {
return NextResponse.redirect(new URL('/auth/login', request.url))
}
if (request.auth?.user?.role !== 'ADMIN') {
return NextResponse.redirect(new URL('/unauthorized', request.url))
}
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
}Using 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('/auth/login')
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
)
}Login Form with Server Action
// app/auth/login/page.tsx
'use client'
import { signIn } from 'next-auth/react'
import { useActionState } from 'react'
async function loginAction(_: any, formData: FormData) {
const result = await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirect: false,
})
if (result?.error) return { error: 'Invalid email or password' }
return { success: true }
}
export default function LoginPage() {
const [state, action, isPending] = useActionState(loginAction, null)
return (
<form action={action} className="space-y-4 max-w-sm mx-auto mt-16">
<h1 className="text-2xl font-bold">Sign in</h1>
{state?.error && <p className="text-red-500 text-sm">{state.error}</p>}
<input name="email" type="email" placeholder="Email" className="input w-full" required />
<input name="password" type="password" placeholder="Password" className="input w-full" required />
<button type="submit" disabled={isPending} className="btn-primary w-full">
{isPending ? 'Signing in…' : 'Sign in'}
</button>
<div className="flex gap-2">
<button type="button" onClick={() => signIn('github')} className="btn-secondary flex-1">
GitHub
</button>
<button type="button" onClick={() => signIn('google')} className="btn-secondary flex-1">
Google
</button>
</div>
</form>
)
}Common Mistakes
- Storing JWTs in
localStorage— usehttpOnlycookies (NextAuth does this by default) - Not validating credentials with Zod before database lookup — allows malformed inputs
- Exposing the raw database user object in session callbacks — only pass needed fields
- Using
strategy: 'database'without connection pooling in serverless — exhausts connections fast - Not rotating secrets —
AUTH_SECRETshould be rotated periodically and stored in a secrets manager
Best Practices
- Use
strategy: 'jwt'for serverless deployments to avoid a session lookup on every request - Protect routes in
middleware.tsat the edge — faster than checking auth in every Server Component - Use the
rolefield in the JWT to avoid an extra database call on every protected page - Always redirect to a custom
pages.signInso you control the UX - Hash passwords with bcrypt (cost factor >= 12) — never store plain text or MD5/SHA1
Key Takeaways
- NextAuth v5 (Auth.js) is redesigned for the Next.js App Router and Edge runtime
- JWT strategy avoids a database session lookup on every request — preferred for serverless
- Middleware-based protection at the Edge is faster and more reliable than per-page checks
- OAuth providers (GitHub, Google) should be preferred over custom credentials when possible
httpOnlycookies prevent XSS attacks from stealing tokens — NextAuth uses them by default- The
jwtandsessioncallbacks are where you extend the session with custom fields likerole - bcrypt with cost factor 12 or higher is the standard for password hashing in 2026
- Clerk is a viable managed alternative to NextAuth for teams that want zero-maintenance auth infrastructure
Advertisement
Related reading
Next.js Authentication — NextAuth.js v5 Complete Guide for 20266 min readClerk in Production — Modern Auth That Just Works for SaaS Apps7 min readJWT Authentication with TypeScript — Secure Implementation 20266 min readPassport.js with TypeScript — Authentication Strategies Guide 20265 min readBuild an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readNext.js Middleware — Authentication, Routing, and Edge Logic6 min read