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

Sanjeev SharmaSanjeev Sharma
5 min read

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 } = handlers

Middleware-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 — use httpOnly cookies (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_SECRET should 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.ts at the edge — faster than checking auth in every Server Component
  • Use the role field in the JWT to avoid an extra database call on every protected page
  • Always redirect to a custom pages.signIn so 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
  • httpOnly cookies prevent XSS attacks from stealing tokens — NextAuth uses them by default
  • The jwt and session callbacks are where you extend the session with custom fields like role
  • 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading