TL;DR — The 3 Approaches
Authentication is the entry point to your entire product. Get it wrong and you're staring down session fixation bugs, token replay attacks, and a password reset flow that locks out real users. Get it right and it fades into the background — invisible infrastructure that just works.
The frustrating reality: most Next.js apps I audit have auth that was copied from a tutorial, never updated, and slowly accumulated security debt. The tutorial was fine in 2022. Your production system in 2026 is not a tutorial.
This guide covers the three viable auth approaches for Next.js, when to pick each one, and the specific production mistakes that will eventually burn you if you don't address them now.
The 3 Approaches: A Side-by-Side Comparison
Before diving into implementation, here's the honest trade-off matrix:
Auth.js (formerly NextAuth.js)
Auth.js is the go-to for Next.js OAuth flows. Version 5 (currently in beta but widely used in production) was rewritten specifically for the App Router. It supports Google, GitHub, Discord, and dozens of other providers out of the box.
Use Auth.js when: you need OAuth sign-in, you want database session storage, you want open-source with no vendor dependency, and you're comfortable with some configuration overhead.
Basic Setup with App Router
// src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
session({ session, token }) {
// Attach user ID to session for use in Server Components
if (token.sub) session.user.id = token.sub;
return session;
},
},
});
// src/app/api/auth/[...nextauth]/route.ts
export { handlers as GET, handlers as POST } from '@/auth';Reading the Session in a Server Component
// src/app/dashboard/page.tsx (Server Component)
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return (
<main>
<h1>Welcome, {session.user.name}</h1>
</main>
);
}Clerk
Clerk is the fastest way to add production-grade auth to a Next.js app. You get prebuilt sign-in/sign-up UI components, MFA, passkeys, organization management, and a full user dashboard — without writing a single auth handler.
Use Clerk when: you're shipping fast, your team's time is worth more than $25/month, and you want features like organizations, device management, and audit logs without building them yourself.
The key integration point in Next.js is middleware:
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isPublicRoute = createRouteMatcher([
'/',
'/sign-in(.*)',
'/sign-up(.*)',
'/api/webhooks(.*)',
]);
export default clerkMiddleware((auth, req) => {
if (!isPublicRoute(req)) {
auth().protect();
}
});
export const config = {
matcher: ['/((?!_next|[^?]*\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', '/(api|trpc)(.*)'],
};Clerk's auth() helper works in both Server Components and Route Handlers. The currentUser() function fetches the full user object when needed.
Roll Your Own JWT
Building your own auth is a serious commitment. You own the security model end to end — which means you own every vulnerability. That said, there are legitimate reasons to do it: you have unusual token requirements, you're integrating with an existing identity provider, or you're building for an enterprise client with specific compliance needs.
If you go this route, use the jose library. It's the Edge-compatible JWT implementation — no native Node.js crypto dependencies — which means it works in Next.js middleware.
// src/lib/auth/jwt.ts
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function signToken(payload: Record<string, unknown>) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch {
return null;
}
}
// Usage in a Route Handler
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await verifyCredentials(email, password);
if (!user) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
const token = await signToken({ sub: user.id, email: user.email });
const res = Response.json({ success: true });
res.headers.set('Set-Cookie',
`token=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600`
);
return res;
}HttpOnly cookies (never localStorage). Rotate your secret regularly and support key rotation. Implement refresh tokens with rotation — single long-lived tokens are a liability. Add a token family tracking mechanism to detect refresh token theft.Protecting Routes with Middleware
Regardless of which auth solution you use, the correct place to enforce authentication in Next.js is middleware.ts. It runs at the Edge before any page renders, which means unauthenticated users never touch your Server Components or data fetching logic.
// middleware.ts — Auth.js + custom JWT approach
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth/jwt';
const PUBLIC_PATHS = ['/', '/login', '/signup', '/api/auth'];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isPublic = PUBLIC_PATHS.some(p => pathname.startsWith(p));
if (isPublic) return NextResponse.next();
const token = req.cookies.get('token')?.value;
const payload = token ? await verifyToken(token) : null;
if (!payload) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};The matcher config is critical. You must exclude static assets and Next.js internals — otherwise middleware runs on every image request, which will destroy your Edge function cold start performance.
The Auth Mistakes That Will Burn You
These are the production failures I see repeatedly across codebases. None of them show up in tutorials. All of them eventually cost real money or security incidents.
When a user changes their password, every existing session — on every device — should be invalidated. JWT-based auth makes this hard by design. The fix: store a session version counter in your database and include it in the token. On password change, increment the counter. Middleware rejects tokens with an old version.
JWTs are base64-encoded, not encrypted. Anyone who gets the token (from logs, proxies, analytics tools) can read the payload. Store only the user ID and role in the JWT. Fetch everything else server-side from the database.
If you issue long-lived access tokens without refresh tokens, a stolen token is valid until expiry. If you issue refresh tokens without rotation, a stolen refresh token is permanently valid. Implement one-time-use refresh tokens: each use invalidates the old token and issues a new one. If a refresh token is used twice, it's a sign of theft — invalidate the entire family.
Your /api/auth/login endpoint accepts unlimited password attempts by default. A basic brute-force attack will enumerate passwords against any account. Rate limit by IP and by email separately. Use exponential backoff, not a fixed window. Consider temporary lockout after N failures.
Which Should You Pick?
Here's the decision logic I use when evaluating auth for a new project:
The worst decision is indecision. Pick an approach, ship it, and spend your engineering time on actual product. Auth is not a differentiator. Your product is.
Conclusion
Authentication in Next.js has never been more approachable. Auth.js gives you solid OAuth without vendor lock-in. Clerk gives you a week of auth work in 30 minutes. Custom JWT gives you complete control when you genuinely need it.
The patterns that actually matter in production: always use HttpOnly cookies, not localStorage. Always invalidate sessions on password change. Always rate limit your auth endpoints. Always verify your token-handling logic can handle rotation correctly.
Get those four things right and the specific library you choose becomes a much less critical decision. The fundamentals are what kill production systems — not your choice of abstraction layer.
