Authentication Guide 2026: NextAuth v5, Clerk, and JWT Best Practices
Advertisement
Authentication in 2026: Pick the Right Tool
Authentication is security-critical. Mistakes cause data breaches. Choose between: NextAuth (self-hosted, free), Clerk (managed, paid), or Auth0/Supabase Auth (cloud, scalable).
- Option 1: NextAuth.js v5 (Auth.js)
- Protected Routes with Middleware
- Session in Server Components
- Role-Based Access Control (RBAC)
- Option 2: Clerk (Managed Auth)
- JWT Security Best Practices
- Authentication Decision Guide
Option 1: NextAuth.js v5 (Auth.js)
npm install next-auth@beta @auth/prisma-adapter
// auth.ts (root)
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/db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
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_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (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: {
jwt({ token, user }) {
if (user) token.role = (user as any).role
return token
},
session({ session, token }) {
if (session.user) session.user.role = token.role as string
return session
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
Protected Routes with Middleware
// middleware.ts
import { auth } from './auth'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isAuthPage = req.nextUrl.pathname.startsWith('/login')
const isProtected = req.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !isLoggedIn) {
return Response.redirect(new URL('/login', req.nextUrl))
}
if (isAuthPage && isLoggedIn) {
return Response.redirect(new URL('/dashboard', req.nextUrl))
}
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Session in Server Components
// Server component — direct session access
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect('/login')
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
)
}
// Client component — use useSession hook
'use client'
import { useSession, signOut } from 'next-auth/react'
export function UserMenu() {
const { data: session, status } = useSession()
if (status === 'loading') return <Spinner />
if (!session) return <a href="/login">Sign in</a>
return (
<div>
<img src={session.user.image!} alt={session.user.name!} className="rounded-full w-8 h-8" />
<button onClick={() => signOut()}>Sign out</button>
</div>
)
}
Role-Based Access Control (RBAC)
// lib/permissions.ts
type Role = 'USER' | 'ADMIN' | 'MODERATOR'
type Permission = 'post:create' | 'post:delete' | 'post:publish' | 'user:manage'
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
USER: ['post:create'],
MODERATOR: ['post:create', 'post:delete', 'post:publish'],
ADMIN: ['post:create', 'post:delete', 'post:publish', 'user:manage'],
}
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false
}
// Server Action with permission check
export async function deletePost(postId: string) {
'use server'
const session = await auth()
if (!session?.user) throw new Error('Not authenticated')
const userRole = session.user.role as Role
if (!hasPermission(userRole, 'post:delete')) {
throw new Error('Insufficient permissions')
}
await prisma.post.delete({ where: { id: postId } })
}
Option 2: Clerk (Managed Auth)
npm install @clerk/nextjs
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/admin(.*)'])
export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect()
})
// app/layout.tsx
import { ClerkProvider, SignInButton, UserButton } from '@clerk/nextjs'
export default function RootLayout({ children }) {
return (
<ClerkProvider>
<html>
<body>
<header>
<SignInButton />
<UserButton afterSignOutUrl="/" />
</header>
{children}
</body>
</html>
</ClerkProvider>
)
}
// Server component
import { auth, currentUser } from '@clerk/nextjs/server'
export default async function Page() {
const { userId } = auth()
const user = await currentUser()
if (!userId) redirect('/sign-in')
return <div>Hello {user?.firstName}</div>
}
JWT Security Best Practices
import jwt from 'jsonwebtoken'
// Access token: short-lived (15 minutes)
function createAccessToken(userId: string): string {
return jwt.sign(
{ sub: userId, type: 'access' },
process.env.JWT_SECRET!,
{ expiresIn: '15m', algorithm: 'HS256' }
)
}
// Refresh token: long-lived (7 days), stored in DB
async function createRefreshToken(userId: string): Promise<string> {
const token = jwt.sign(
{ sub: userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' }
)
// Store hash in DB to enable rotation/revocation
await prisma.refreshToken.create({
data: {
tokenHash: hashToken(token),
userId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
})
return token
}
// Token rotation — issue new refresh token on each use
async function refreshAccessToken(refreshToken: string) {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!)
const stored = await prisma.refreshToken.findFirst({
where: { tokenHash: hashToken(refreshToken), userId: payload.sub },
})
if (!stored || stored.expiresAt < new Date()) {
throw new Error('Invalid refresh token')
}
// Rotate: delete old, create new
await prisma.refreshToken.delete({ where: { id: stored.id } })
const newRefreshToken = await createRefreshToken(payload.sub as string)
const newAccessToken = createAccessToken(payload.sub as string)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
Authentication Decision Guide
| Requirement | NextAuth | Clerk | Auth0 |
|---|---|---|---|
| Free | ✓ | Limited | Limited |
| Self-hosted | ✓ | ||
| Built-in UI | ✓ | ✓ | |
| MFA | Manual | ✓ | ✓ |
| Organizations | Manual | ✓ | ✓ |
| Social login | ✓ | ✓ | ✓ |
| Passkeys | v5 | ✓ | ✓ |
For side projects: NextAuth. For startups that need MFA and organizations quickly: Clerk. For enterprise: Auth0 or Okta.
Advertisement