Auth in Next.js 2025: NextAuth v5 vs Clerk vs Auth0 vs Self-Built
A comprehensive deep dive comparing NextAuth v5 (Auth.js), Clerk, Auth0, and self-built authentication in Next.js 2025. Covers setup complexity, cost at scale, JWT vs database sessions, App Router integration, middleware, social OAuth, MFA support, and real code examples for each approach.
Auth in Next.js 2025: The Definitive Comparison
Authentication is one of those decisions that follows your project forever. Pick the wrong solution and you'll be wrestling with it on every feature — session bugs, OAuth edge cases, middleware headaches, MFA retrofits. In 2025, the Next.js ecosystem has matured significantly, and you have four serious options: NextAuth v5 (Auth.js), Clerk, Auth0, and rolling your own. Each has a legitimate use case. This guide cuts through the noise.
The Landscape in 2025
The App Router changed everything. With React Server Components, server actions, and edge middleware as first-class primitives, auth libraries had to rethink their architectures from scratch. NextAuth v4 was designed for Pages Router. Clerk was built cloud-first. Auth0 adapted its SDK. The self-built path got more viable with better primitives like jose, oslo, and Lucia (now archived but influential).
Let's break down each option across the dimensions that actually matter: setup complexity, cost at scale, session strategy, App Router integration, social OAuth, and MFA support.
NextAuth v5 (Auth.js)
Overview
NextAuth v5 — now branded as Auth.js — is a ground-up rewrite designed for the App Router and the Edge Runtime. It supports adapters for Prisma, Drizzle, MongoDB, and more. It's open source, free forever, and community-maintained.
Setup Complexity: Medium
The configuration is clean but has some sharp edges. You define your auth config in a single file, and it propagates to both edge middleware and server components.
// 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 { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/db"
import { users } from "@/db/schema"
import { eq } from "drizzle-orm"
import bcrypt from "bcryptjs"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub,
Google,
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const user = await db.query.users.findFirst({
where: eq(users.email, credentials.email as string),
})
if (!user) return null
const valid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
)
return valid ? user : null
},
}),
],
session: { strategy: "jwt" }, // or "database"
})
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth"
App Router Integration
Auth.js v5 works beautifully with Server Components. Call auth() anywhere on the server — in layouts, pages, or server actions — without any special client wrapper.
// 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 (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
</div>
)
}
Middleware
// middleware.ts
export { auth as middleware } from "@/auth"
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
}
Session Strategy: JWT vs Database
With strategy: "jwt", sessions are stateless — stored in an encrypted cookie, no DB reads on every request. This is fast and works on the Edge. With strategy: "database", every request verifies against your adapter, giving you instant session revocation at the cost of added latency. For most apps, JWT with short expiry (15 min access + 7 day refresh) is the right default. Use database sessions when you need to force-logout users immediately — for example, after a privilege escalation or account compromise.
MFA Support
No built-in MFA. You implement TOTP yourself using libraries like @otplib/preset-default, gating it in your authorize callback and storing encrypted secrets in your user table. Not insurmountable, but not trivial either.
Cost at Scale
Free forever. You pay for your own database and infrastructure. At 1M monthly active users, your cost is your Postgres bill — not an auth vendor's pricing tier.
When to Use NextAuth v5
- You want full control without building everything from scratch
- Budget constraints rule out per-MAU pricing
- You're comfortable owning your user schema and migrations
- You don't need built-in MFA UI or enterprise SSO out of the box
Clerk
Overview
Clerk is the "batteries included" option. It's a hosted auth platform with pre-built React components, a management dashboard, webhooks, organization/team support, and first-class Next.js SDK. It feels like magic until you read the pricing at scale.
Setup Complexity: Low
The quickstart is genuinely fast. Install the SDK, add environment variables, wrap your app, and configure the middleware. You're in production in under 30 minutes.
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
const isProtectedRoute = createRouteMatcher([
"/dashboard(.*)",
"/settings(.*)",
])
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect()
})
export const config = {
matcher: ["/((?!_next|[^?]*\.(?:html?|css|js(?!on)|ico|png|jpg|jpeg|gif|webp|svg|ttf|woff2?)).*)", "/(api|trpc)(.*)"],
}
// app/dashboard/page.tsx
import { auth, currentUser } from "@clerk/nextjs/server"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const { userId } = await auth()
if (!userId) redirect("/sign-in")
const user = await currentUser()
return <div>Welcome, {user?.firstName}</div>
}
Pre-Built Components
Clerk ships <SignIn />, <SignUp />, <UserButton />, and <UserProfile /> that handle the full auth flow — including social login, MFA enrollment, and passkey registration. No custom UI code required.
// app/(auth)/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs"
export default function SignInPage() {
return (
<main className="flex justify-center items-center min-h-screen">
<SignIn />
</main>
)
}
Session Strategy
Clerk manages sessions entirely on their infrastructure. They issue short-lived JWTs (60 seconds) with automatic silent rotation. You don't control the session store — this is both a feature (you don't have to think about it) and a constraint (you can't deeply customize session behavior).
MFA Support
Excellent and zero-config. TOTP, SMS OTP, and backup codes are available out of the box. Users self-enroll through the <UserProfile /> component. Passkeys are supported. No additional code required on your end.
Cost at Scale
Clerk's free tier covers 10,000 MAU. Beyond that: $0.02/MAU on Pro. At 100K MAU that's ~$1,800/month. At 500K MAU it's ~$9,800/month. At 1M MAU, you're looking at ~$20,000/month. Volume discounts exist but require negotiation. This is the number that forces a migration for high-growth consumer apps.
When to Use Clerk
- B2B SaaS with organization and multi-tenancy requirements (Clerk's org support is best-in-class)
- Early-stage product where shipping speed matters more than infrastructure cost
- You want MFA, passkeys, social login, and a profile UI with zero implementation effort
- Your MAU will stay comfortably under 10K, or your unit economics support per-MAU pricing
Auth0 (Okta)
Overview
Auth0 (acquired by Okta in 2021) is the enterprise-grade option. It's been around since 2013, supports every OAuth/OIDC/SAML scenario imaginable, and carries compliance certifications: SOC 2, HIPAA, ISO 27001, FedRAMP. It's the default choice when an enterprise deal depends on SSO with a corporate identity provider.
Setup Complexity: Medium-High
Auth0 uses the OIDC authorization code flow. Their Next.js SDK has been updated for App Router but still shows some Pages Router heritage in its design.
// app/api/auth/[auth0]/route.ts
import { handleAuth } from "@auth0/nextjs-auth0"
export const GET = handleAuth()
// app/layout.tsx
import { UserProvider } from "@auth0/nextjs-auth0/client"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<UserProvider>{children}</UserProvider>
</body>
</html>
)
}
// app/dashboard/page.tsx
import { getSession } from "@auth0/nextjs-auth0"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const session = await getSession()
if (!session?.user) redirect("/api/auth/login")
return <div>Welcome, {session.user.name}</div>
}
Middleware
// middleware.ts
import { withMiddlewareAuthRequired } from "@auth0/nextjs-auth0/edge"
export default withMiddlewareAuthRequired()
export const config = {
matcher: "/dashboard/:path*",
}
Session Strategy
Auth0 issues OIDC tokens — ID token, access token, and refresh token. The SDK stores the session in an encrypted cookie by default (stateless) but can be configured with a custom session store. Token rotation is handled automatically via the Auth0 tenant.
MFA and Enterprise Features
Comprehensive. TOTP, SMS, email OTP, push notifications (Auth0 Guardian), WebAuthn/Passkeys, and adaptive MFA (risk-based) on higher tiers. Enterprise SAML 2.0 SSO is a core feature — connect Okta, Azure AD, Google Workspace, or any SAML-compliant IdP. This is Auth0's primary differentiator over other options.
Cost at Scale
Free tier: 7,500 MAU. B2C pricing is roughly comparable to Clerk at low volumes. Enterprise pricing (which unlocks SAML, custom domains, advanced MFA) is negotiated and can be significant. For compliance-heavy regulated industries, the cost is typically justified by the compliance certifications and audit trail features.
When to Use Auth0
- Enterprise sales cycles requiring SOC 2, HIPAA, or FedRAMP compliance documentation
- SAML SSO integration with existing corporate identity providers
- Complex authorization logic via Auth0 Actions/Rules pipelines
- Regulated industries: healthcare, finance, government, defense
Self-Built Authentication
Overview
Building your own auth in 2025 is more viable than it sounds — but only if you approach it carefully. The ecosystem has matured with solid primitives: jose for JWT operations, @node-rs/argon2 for password hashing, arctic for OAuth provider integrations, and oslo for cryptographic utilities. The Lucia project (now archived) left behind excellent documentation on secure session patterns.
Warning: Self-built auth means you own every security decision. If you're not confident about timing-safe string comparisons, CSRF protection, cookie security flags, and token rotation, start with a library. The cost of getting this wrong is user data exposure.
Core Session Management
// lib/auth/session.ts
import { SignJWT, jwtVerify } from "jose"
import { cookies } from "next/headers"
const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function createSession(userId: string): Promise<void> {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret)
const cookieStore = await cookies()
cookieStore.set("session", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 15,
path: "/",
})
}
export async function verifySession(): Promise<{ userId: string } | null> {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) return null
try {
const { payload } = await jwtVerify(token, secret)
return payload as { userId: string }
} catch {
return null
}
}
Server Action Login
// app/actions/auth.ts
"use server"
import { db } from "@/db"
import { users } from "@/db/schema"
import { verify } from "@node-rs/argon2"
import { createSession } from "@/lib/auth/session"
import { redirect } from "next/navigation"
import { eq } from "drizzle-orm"
export async function login(_: unknown, formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (!user || !user.passwordHash) {
// Constant-time delay prevents user enumeration via timing
await new Promise(r => setTimeout(r, 200 + Math.random() * 100))
return { error: "Invalid credentials" }
}
const valid = await verify(user.passwordHash, password)
if (!valid) return { error: "Invalid credentials" }
await createSession(user.id)
redirect("/dashboard")
}
Edge Middleware
// middleware.ts
import { NextRequest, NextResponse } from "next/server"
import { jwtVerify } from "jose"
const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function middleware(req: NextRequest) {
const token = req.cookies.get("session")?.value
if (!token) {
return NextResponse.redirect(new URL("/login", req.url))
}
try {
await jwtVerify(token, secret)
return NextResponse.next()
} catch {
const response = NextResponse.redirect(new URL("/login", req.url))
response.cookies.delete("session")
return response
}
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
}
Social OAuth with Arctic
For social login without NextAuth, the arctic library (from the Lucia author) provides a clean, minimal OAuth abstraction:
// app/api/auth/github/route.ts
import { GitHub, generateState } from "arctic"
import { cookies } from "next/headers"
const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!
)
export async function GET() {
const state = generateState()
const url = github.createAuthorizationURL(state, ["user:email"])
const cookieStore = await cookies()
cookieStore.set("github_oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 10,
sameSite: "lax",
})
return Response.redirect(url.toString())
}
Cost at Scale
Cheapest at scale. Auth costs are purely infrastructure — Postgres for users and sessions, optionally Redis for token blacklisting. At 1M MAU, you're paying for database capacity and query performance, not per-user fees. This is the only option where your auth cost stays flat as you grow.
When to Use Self-Built Auth
- Strict data residency requirements — no user data touches external infrastructure
- Very high user volumes where per-MAU pricing would be prohibitive
- Deep customization needs no library can accommodate
- Strong internal security engineering capability to own the implementation
Head-to-Head Comparison
Setup Time to Production-Ready
- Clerk: ~30 minutes — social login, MFA, and profile UI included
- NextAuth v5: ~2 hours — adapter setup, session config, provider configuration
- Auth0: ~3-4 hours — tenant setup, actions/rules, SDK integration
- Self-Built: ~2–3 days minimum for a secure, complete implementation
Monthly Cost at 100K MAU
- Clerk: ~$1,800/month (Pro plan, $0.02/MAU)
- Auth0: ~$1,500–$3,000/month (varies by features and tier)
- NextAuth v5: ~$20–$100/month (your Postgres infrastructure)
- Self-Built: ~$20–$100/month (same — your infrastructure only)
App Router and RSC Compatibility
- NextAuth v5: ✅ Built for it —
auth()works in RSC, layouts, server actions, edge middleware - Clerk: ✅ Excellent —
auth()andcurrentUser()are fully RSC-compatible - Auth0: ⚠️ Works but the SDK shows Pages Router heritage; watch for footguns around async
- Self-Built: ✅ You control everything — RSC compatibility is built in by design
MFA and Social OAuth
- Clerk: TOTP, SMS, passkeys built-in; 20+ social providers via dashboard toggle
- Auth0: TOTP, SMS, WebAuthn, adaptive MFA, SAML SSO; 30+ social providers
- NextAuth v5: Social providers via config; MFA is DIY in credentials authorize
- Self-Built: Both require manual implementation using
arctic+@otplib/preset-default
The Decision Framework
Here's the honest breakdown:
- Building fast, early-stage B2B SaaS? → Clerk. The organization and team features alone are worth the cost.
- Enterprise deal requiring SAML/compliance certifications? → Auth0. Non-negotiable when procurement requires it.
- Open source project, indie app, or budget-constrained product? → NextAuth v5. Best value, full control, zero vendor lock-in.
- Data residency requirements, massive consumer scale, or deeply custom auth flows? → Self-Built.
The worst choice is spending weeks building self-auth for a startup MVP that needs to ship, or paying $20,000/month in Clerk fees for a consumer app that hit 1M casual free users. Match the tool to your current constraints, not your hypothetical future needs.
Final Thoughts
Auth in Next.js 2025 is better than it has ever been. The App Router's server-first model means auth state is available at every layer — layouts, pages, middleware, server actions — without prop drilling or client-side waterfalls. All four options integrate cleanly with this model when used correctly.
The "best" solution is the one that fits your team's capability, your user base's size, and your product's compliance requirements. Don't let framework loyalty or ecosystem hype drive the decision. Let your actual constraints drive it.
And whatever you choose: implement CSRF protection, set httpOnly and Secure cookie flags, enforce HTTPS everywhere, rotate your secrets, and audit your auth code regularly. The library is a starting point — not a security guarantee.
Admin
Cal.com
Open source scheduling — self-host your booking system, replace Calendly. Free & privacy-first.
Comments (0)
Sign in to comment
No comments yet. Be the first to comment!