Authentication Guide 2026: NextAuth v5, Clerk, and JWT Best Practices

Sanjeev SharmaSanjeev Sharma
5 min read

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)

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

RequirementNextAuthClerkAuth0
FreeLimitedLimited
Self-hosted
Built-in UI
MFAManual
OrganizationsManual
Social login
Passkeysv5

For side projects: NextAuth. For startups that need MFA and organizations quickly: Clerk. For enterprise: Auth0 or Okta.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro